Editor/Prism/Text/CodeEditorWidget.cs

An editor widget for the Prism code editor in the Editor package. Implements a custom-painted, virtualised text editor with caret, selection, incremental lexing, folding, bracket matching, diagnostics, search highlights, completion host hooks, scrolling and mouse/keyboard input handling.

Native InteropExternal Download
using Editor.Prism.Core;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Ui;
using System.Text;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;

namespace Editor.Prism.Text;

/// <summary>A problem to underline in the editor, resolved to zero-based editor coordinates.</summary>
public sealed record CodeDiagnostic( DiagnosticSeverity Severity, string Code, string Message, TextRange Range )
{
	/// <summary>Extra detail shown in the hover tooltip and the diagnostics list.</summary>
	public string Detail { get; init; }

	/// <summary>The originating pipeline diagnostic, when there was one.</summary>
	public PrismDiagnostic Source { get; init; }

	/// <summary>
	/// Converts a pipeline diagnostic. One-based compiler coordinates become zero-based editor
	/// coordinates; a span with no end widens to the word under its start.
	/// </summary>
	public static CodeDiagnostic From( PrismDiagnostic diagnostic, TextDocument document )
	{
		if ( diagnostic is null )
			return null;

		var range = TextRange.Empty;

		if ( diagnostic.Span.HasValue )
		{
			var span = diagnostic.Span.Value;
			var startLine = Math.Max( 0, span.Line - 1 );
			var startColumn = Math.Max( 0, span.Column - 1 );
			var endLine = span.EndLine > 0 ? Math.Max( 0, span.EndLine - 1 ) : startLine;
			var endColumn = span.EndColumn > 0 ? Math.Max( 0, span.EndColumn - 1 ) : startColumn;

			var start = new TextPosition( startLine, startColumn );
			var end = new TextPosition( endLine, endColumn );

			if ( end <= start && document is not null )
			{
				var text = document.GetLine( startLine );
				var column = Math.Clamp( startColumn, 0, Math.Max( 0, text.Length ) );
				var wordEnd = column;

				while ( wordEnd < text.Length && TextEditController.IsWordChar( text[wordEnd] ) )
					wordEnd++;

				if ( wordEnd == column )
					wordEnd = Math.Min( text.Length, column + 1 );

				end = new TextPosition( startLine, wordEnd );
			}

			range = new TextRange( start, end );
		}

		if ( document is not null )
			range = document.Clamp( range );

		return new CodeDiagnostic( diagnostic.Severity, diagnostic.Code, diagnostic.Message, range )
		{
			Detail = diagnostic.Detail,
			Source = diagnostic
		};
	}
}

/// <summary>A key press reduced to plain data, so a completion host never has to touch Qt's ref structs.</summary>
public readonly record struct CodeKeyInfo( KeyCode Key, string Text, bool Ctrl, bool Shift, bool Alt );

/// <summary>
/// The hook a completion package attaches to an editor. Everything is optional: an editor with no
/// host behaves exactly as it does today.
/// </summary>
public interface ICodeCompletionHost
{
	/// <summary>Whether a popup is currently on screen, which changes how Escape and Enter are routed.</summary>
	bool IsOpen { get; }

	/// <summary>Called before the editor handles a key. Return true to swallow it.</summary>
	bool HandleKey( CodeEditorWidget editor, CodeKeyInfo key );

	/// <summary>Called after text was typed, with the text that was inserted.</summary>
	void OnTextInserted( CodeEditorWidget editor, string text );

	/// <summary>Called after the caret moved for any reason.</summary>
	void OnCaretMoved( CodeEditorWidget editor );

	/// <summary>Called when the user explicitly asks for completion, usually Ctrl+Space.</summary>
	void RequestCompletion( CodeEditorWidget editor );

	/// <summary>Closes any popup.</summary>
	void Dismiss();
}

/// <summary>
/// The editable code surface: a virtualised, fully custom-painted text editor built on
/// <see cref="BaseScrollWidget"/>. It owns input, geometry and scrolling;
/// <see cref="TextEditController"/> owns meaning and <see cref="CodeEditorRenderer"/> owns pixels.
/// <para>
/// Known limitation: there is no <c>QInputMethodEvent</c> binding in the managed editor surface, so
/// IME composition (CJK, dead-key candidate windows) does not work in this widget. Latin input,
/// AltGr and dead keys do, because Qt resolves those into <c>KeyEvent.Text</c> before we see them.
/// </para>
/// </summary>
public sealed class CodeEditorWidget : BaseScrollWidget
{
	readonly List<CodeDiagnostic> _diagnostics = new();
	readonly List<TextRange> _searchMatches = new();
	readonly List<TextRange> _occurrences = new();

	RealTimeSince _sinceCaretMoved;
	RealTimeSince _sinceLastClick;
	RealTimeSince _sinceEdit;

	TextPosition _lastClickPosition;
	int _clickCount;

	bool _dragging;
	bool _gutterDragging;
	bool _boxDragging;
	int _gutterDragAnchorLine;
	Vector2 _lastMouseLocal;

	bool _caretOn = true;
	bool _occurrencesDirty = true;
	bool _metricsValid;
	bool _settlePending;

	int _maxColumnsVersion = -1;
	int _maxColumns;

	int _fontSize = PrismTheme.CodeSize;
	string _language = "hlsl";
	bool _readOnly;

	/// <summary>Creates an editor over a fresh empty document.</summary>
	public CodeEditorWidget( Widget parent = null ) : base( parent )
	{
		FocusMode = FocusMode.TabOrClickOrWheel;
		MouseTracking = true;
		SmoothScrolling = false;
		ContextMenuEnabled = true;
		Cursor = CursorShape.IBeam;
		HorizontalScrollbarMode = ScrollbarMode.Auto;
		VerticalScrollbarMode = ScrollbarMode.Auto;

		Renderer = new CodeEditorRenderer( this );

		// The renderer merges adjacent tokens that would be drawn identically, so where one run ends and
		// the next begins is a function of the palette. A theme file that gives two token classes
		// different colours has to re-split every cached row, not just repaint it.
		PrismTheme.Changed += OnThemeChanged;

		SetDocument( new TextDocument(), "hlsl" );
		RecomputeMetrics();
	}

	void OnThemeChanged()
	{
		if ( !this.IsValid() )
			return;

		// A theme file can move the monospace family as well as the palette, so this is the same work a
		// font-size change does — re-measure the advance, re-fit the scrollbars — plus dropping the
		// cached rows, whose run boundaries were split against the old colours.
		Renderer.InvalidateRows();
		RecomputeMetrics();
		LayoutScrollbars();
		Update();
	}

	// ---- model ------------------------------------------------------------

	/// <summary>The document being edited. Never null.</summary>
	public TextDocument Document { get; private set; }

	/// <summary>Commands and caret state.</summary>
	public TextEditController Controller { get; private set; }

	/// <summary>The live caret set. Shorthand for <c>Controller.Selection</c>.</summary>
	public SelectionSet Selection => Controller.Selection;

	/// <summary>Incremental syntax highlighting.</summary>
	public IncrementalLexer Lexer { get; private set; }

	/// <summary>Fold regions and the document-line to visual-row mapping.</summary>
	public FoldingModel Folding { get; private set; }

	/// <summary>Bracket matching.</summary>
	public BracketMatcher Brackets { get; private set; }

	/// <summary>All painting.</summary>
	public CodeEditorRenderer Renderer { get; }

	/// <summary>Optional completion host, attached by the language-intelligence package.</summary>
	public ICodeCompletionHost Completion { get; set; }

	/// <summary>
	/// Last-chance key hook for the hosting window. The editor swallows every shortcut while focused —
	/// it has to, or global editor binds fire mid-typing — so a host that wants its own accelerators
	/// routes them through here. Return true when the key was consumed.
	/// </summary>
	public Func<CodeEditorWidget, CodeKeyInfo, bool> UnhandledKey { get; set; }

	// ---- events -----------------------------------------------------------

	/// <summary>Raised after the document changed through this editor.</summary>
	public event Action<CodeEditorWidget> TextChanged;

	/// <summary>Raised after any caret moved.</summary>
	public event Action<CodeEditorWidget> CaretMoved;

	/// <summary>Raised when the user presses the save shortcut.</summary>
	public event Action<CodeEditorWidget> SaveRequested;

	/// <summary>Raised when the user asks for the find bar. The argument is true for find-and-replace.</summary>
	public event Action<CodeEditorWidget, bool> FindRequested;

	/// <summary>Raised when the user asks to jump to a line.</summary>
	public event Action<CodeEditorWidget> GoToLineRequested;

	/// <summary>Raised when the user asks to find the next or previous search match. The argument is the direction.</summary>
	public event Action<CodeEditorWidget, int> FindStepRequested;

	/// <summary>
	/// Raised once typing has stopped for <see cref="PrismConstants.TextDebounceMs"/>. This is the hook
	/// a diagnostics service subscribes to instead of running a validator on every keystroke.
	/// </summary>
	public event Action<CodeEditorWidget> TextSettled;

	// ---- options ----------------------------------------------------------

	/// <summary>Language id driving the lexer and the comment tokens.</summary>
	public string Language
	{
		get => _language;
		set
		{
			var resolved = string.IsNullOrWhiteSpace( value ) ? "text" : value;

			if ( _language == resolved )
				return;

			_language = resolved;
			Lexer?.SetLanguage( resolved );
			Folding?.Invalidate();
			Update();
		}
	}

	/// <summary>When set, every editing command becomes a no-op and the caret is drawn dimmed.</summary>
	public override bool ReadOnly
	{
		get => _readOnly;
		set
		{
			_readOnly = value;

			if ( Controller is not null )
				Controller.ReadOnly = value;

			Update();
		}
	}

	/// <summary>Font height in pixels. Qt only accepts whole pixel sizes.</summary>
	public int FontSize
	{
		get => _fontSize;
		set
		{
			var clamped = Math.Clamp( value, 8, 40 );

			if ( _fontSize == clamped )
				return;

			_fontSize = clamped;
			RecomputeMetrics();
			LayoutScrollbars();
			Update();
		}
	}

	/// <summary>Width of a tab stop in columns.</summary>
	public int TabSize { get; set; } = 4;

	/// <summary>Whether the line-number gutter is drawn.</summary>
	public bool ShowLineNumbers { get; set; } = true;

	/// <summary>Whether the fold margin is drawn.</summary>
	public bool ShowFoldMargin { get; set; } = true;

	/// <summary>Whether indent guides are drawn.</summary>
	public bool ShowIndentGuides { get; set; } = true;

	/// <summary>Whether spaces and tabs are drawn as glyphs.</summary>
	public bool ShowWhitespace { get; set; }

	/// <summary>Whether the caret's line gets a background tint.</summary>
	public bool HighlightCurrentLine { get; set; } = true;

	/// <summary>Whether other occurrences of the word under the caret are boxed.</summary>
	public bool HighlightOccurrences { get; set; } = true;

	/// <summary>Whether a vertical rule is drawn at <see cref="RulerColumn"/>.</summary>
	public bool ShowRuler { get; set; }

	/// <summary>Column the optional vertical rule sits at.</summary>
	public int RulerColumn { get; set; } = 120;

	// ---- metrics ----------------------------------------------------------

	/// <summary>Advance width of one monospace character, measured once per font change.</summary>
	public float CharWidth { get; private set; } = 8f;

	/// <summary>Height of one row.</summary>
	public float LineHeight { get; private set; } = 16f;

	/// <summary>Total width of the gutter: markers, line numbers and the fold margin.</summary>
	public float GutterWidth { get; private set; } = 48f;

	/// <summary>Width reserved for diagnostic icons and the modified bar.</summary>
	public float MarkerMarginWidth => 14f;

	/// <summary>Width reserved for fold arrows.</summary>
	public float FoldMarginWidth => ShowFoldMargin ? 14f : 0f;

	/// <summary>Width reserved for line numbers.</summary>
	public float LineNumberWidth { get; private set; } = 30f;

	/// <summary>Horizontal scroll offset in pixels.</summary>
	public float ScrollX => HorizontalScrollbar is { IsValid: true } ? HorizontalScrollbar.Value : 0f;

	/// <summary>Vertical scroll offset in pixels.</summary>
	public float ScrollY => VerticalScrollbar is { IsValid: true } ? VerticalScrollbar.Value : 0f;

	/// <summary>First visual row inside the viewport.</summary>
	public int FirstVisibleRow => Math.Max( 0, (int)MathF.Floor( ScrollY / Math.Max( 1f, LineHeight ) ) );

	/// <summary>Number of visual rows the viewport can show, rounded up.</summary>
	public int VisibleRowCount => Math.Max( 1, (int)MathF.Ceiling( Height / Math.Max( 1f, LineHeight ) ) + 1 );

	/// <summary>Total number of visual rows, folding applied.</summary>
	public int VisualRowCount => Folding is null ? Document.LineCount : Folding.VisualCount;

	/// <summary>Whether the caret is in its visible blink phase.</summary>
	public bool CaretVisible => _caretOn && IsFocused;

	/// <summary>The primary caret's position.</summary>
	public TextPosition CaretPosition => Controller.Selection.Primary.Position;

	// ---- diagnostics and highlights ---------------------------------------

	/// <summary>Problems drawn as squiggles and gutter icons.</summary>
	public IReadOnlyList<CodeDiagnostic> Diagnostics => _diagnostics;

	/// <summary>Search matches drawn as highlight boxes.</summary>
	public IReadOnlyList<TextRange> SearchMatches => _searchMatches;

	/// <summary>Index into <see cref="SearchMatches"/> of the match drawn as current, or -1.</summary>
	public int CurrentSearchMatch { get; private set; } = -1;

	/// <summary>Occurrences of the word under the caret.</summary>
	public IReadOnlyList<TextRange> Occurrences
	{
		get
		{
			EnsureOccurrences();
			return _occurrences;
		}
	}

	/// <summary>Replaces the diagnostic set, converting from pipeline diagnostics.</summary>
	public void SetDiagnostics( IEnumerable<PrismDiagnostic> diagnostics )
	{
		_diagnostics.Clear();

		if ( diagnostics is not null )
		{
			foreach ( var diagnostic in diagnostics )
			{
				var converted = CodeDiagnostic.From( diagnostic, Document );

				if ( converted is not null )
					_diagnostics.Add( converted );
			}
		}

		Update();
	}

	/// <summary>Replaces the diagnostic set directly.</summary>
	public void SetDiagnostics( IEnumerable<CodeDiagnostic> diagnostics )
	{
		_diagnostics.Clear();

		if ( diagnostics is not null )
		{
			foreach ( var diagnostic in diagnostics )
			{
				if ( diagnostic is not null )
					_diagnostics.Add( diagnostic );
			}
		}

		Update();
	}

	/// <summary>Removes every diagnostic.</summary>
	public void ClearDiagnostics()
	{
		if ( _diagnostics.Count == 0 )
			return;

		_diagnostics.Clear();
		Update();
	}

	/// <summary>The diagnostic covering a position, preferring errors, or null.</summary>
	public CodeDiagnostic DiagnosticAt( TextPosition position )
	{
		CodeDiagnostic best = null;

		for ( var i = 0; i < _diagnostics.Count; i++ )
		{
			var diagnostic = _diagnostics[i];

			if ( !diagnostic.Range.ContainsInclusive( position ) )
				continue;

			if ( best is null || diagnostic.Severity > best.Severity )
				best = diagnostic;
		}

		return best;
	}

	/// <summary>Replaces the search-match highlight set.</summary>
	public void SetSearchMatches( IReadOnlyList<TextRange> matches, int current = -1 )
	{
		_searchMatches.Clear();

		if ( matches is not null )
			_searchMatches.AddRange( matches );

		CurrentSearchMatch = current;
		Update();
	}

	// ---- document ---------------------------------------------------------

	/// <summary>Swaps in a different document, rebuilding every derived model.</summary>
	public void SetDocument( TextDocument document, string language = null )
	{
		if ( document is null )
			return;

		if ( Document is not null )
			Document.Changed -= OnDocumentChanged;

		Lexer?.Detach();
		Folding?.Detach();

		Document = document;
		Document.Changed += OnDocumentChanged;

		Controller = new TextEditController( document )
		{
			ReadOnly = _readOnly,
			VisualColumnOf = position => VisualColumn( position.Line, position.Column ),
			PositionFromVisualColumn = ( line, column ) => new TextPosition( line, CharIndexFromVisual( line, column ) ),
			StepLine = ( line, direction ) => Folding is null
				? Math.Clamp( line + direction, 0, Document.LineCount - 1 )
				: Folding.StepVisible( line, direction )
		};

		Controller.Changed += OnControllerChanged;
		Controller.CaretChanged += OnControllerCaretChanged;

		Lexer = new IncrementalLexer( document );
		Folding = new FoldingModel( document, Lexer );
		Brackets = new BracketMatcher( document, Lexer );
		Controller.Brackets = Brackets;

		_language = null;
		Language = string.IsNullOrWhiteSpace( language ) ? "hlsl" : language;

		_diagnostics.Clear();
		_searchMatches.Clear();
		_occurrencesDirty = true;
		_maxColumnsVersion = -1;

		// The row visuals key off the identity of the outgoing document's line strings, so every entry
		// is dead the moment the document is replaced. Dropping them keeps a widget that is reused for
		// tab after tab from carrying a thousand rows of the file before last.
		Renderer.InvalidateRows();

		LayoutScrollbars();
		Update();
	}

	void OnDocumentChanged( TextDocument document, TextChange change )
	{
		_occurrencesDirty = true;
		_maxColumnsVersion = -1;
		_sinceEdit = 0;
		_settlePending = true;

		for ( var i = 0; i < _diagnostics.Count; i++ )
			_diagnostics[i] = _diagnostics[i] with { Range = change.Map( _diagnostics[i].Range ) };

		for ( var i = 0; i < _searchMatches.Count; i++ )
			_searchMatches[i] = change.Map( _searchMatches[i] );

		LayoutScrollbars();
		Update();
	}

	void OnControllerChanged()
	{
		_occurrencesDirty = true;
		TextChanged?.Invoke( this );
		Update();
	}

	void OnControllerCaretChanged()
	{
		_sinceCaretMoved = 0;
		_caretOn = true;
		_occurrencesDirty = true;
		CaretMoved?.Invoke( this );
		Completion?.OnCaretMoved( this );
		Update();
	}

	// ---- geometry ---------------------------------------------------------

	/// <summary>Recomputes the monospace advance and line height for the current font and DPI.</summary>
	public void RecomputeMetrics()
	{
		var measured = false;

		PrismLog.Guard( "Prism.Text: measure font", () =>
		{
			using ( Paint.ToPixmap( new Pixmap( 8, 8 ) ) )
			{
				Paint.SetFont( PrismTheme.MonospaceFamily, _fontSize, 400, false, true );
				var size = Paint.MeasureText( new string( '0', 64 ) );

				if ( size.x > 1f && size.y > 1f )
				{
					CharWidth = size.x / 64f;
					LineHeight = MathF.Ceiling( size.y ) + 2f;
					measured = true;
				}
			}
		} );

		if ( !measured )
		{
			CharWidth = _fontSize * 0.6f;
			LineHeight = MathF.Ceiling( _fontSize * 1.45f );
		}

		_metricsValid = true;
		UpdateGutterWidth();
	}

	void UpdateGutterWidth()
	{
		var digits = Math.Max( 3, (Document?.LineCount ?? 1).ToString().Length );
		LineNumberWidth = ShowLineNumbers ? CharWidth * digits + 10f : 0f;
		GutterWidth = MarkerMarginWidth + LineNumberWidth + FoldMarginWidth;
	}

	/// <summary>
	/// Expands a line's tabs into spaces and records the visual column of every character index.
	/// Both buffers are cleared first, so callers can reuse them across a whole paint.
	/// </summary>
	public void BuildLineLayout( int line, StringBuilder expanded, List<int> visualOfChar )
	{
		expanded?.Clear();
		visualOfChar?.Clear();

		var text = Document.GetLine( line );
		var column = 0;
		var tab = Math.Max( 1, TabSize );

		for ( var i = 0; i < text.Length; i++ )
		{
			visualOfChar?.Add( column );

			var c = text[i];

			if ( c == '\t' )
			{
				var width = tab - (column % tab);
				expanded?.Append( ' ', width );
				column += width;
			}
			else
			{
				expanded?.Append( c < ' ' ? ' ' : c );
				column++;
			}
		}

		visualOfChar?.Add( column );
	}

	/// <summary>The visual column of a character index, with tabs expanded.</summary>
	public int VisualColumn( int line, int charIndex )
	{
		var text = Document.GetLine( line );
		var column = 0;
		var tab = Math.Max( 1, TabSize );
		var limit = Math.Clamp( charIndex, 0, text.Length );

		for ( var i = 0; i < limit; i++ )
		{
			if ( text[i] == '\t' )
				column += tab - (column % tab);
			else
				column++;
		}

		if ( charIndex > text.Length )
			column += charIndex - text.Length;

		return column;
	}

	/// <summary>The character index nearest a visual column.</summary>
	public int CharIndexFromVisual( int line, int visualColumn )
	{
		var text = Document.GetLine( line );
		var column = 0;
		var tab = Math.Max( 1, TabSize );

		for ( var i = 0; i < text.Length; i++ )
		{
			var width = text[i] == '\t' ? tab - (column % tab) : 1;

			if ( visualColumn < column + (width + 1) / 2 )
				return i;

			if ( visualColumn < column + width )
				return i + 1;

			column += width;
		}

		return text.Length;
	}

	/// <summary>The widget-local rectangle of one visual row's text area.</summary>
	public float RowTop( int visualRow ) => visualRow * LineHeight - ScrollY;

	/// <summary>Converts a document position to widget-local pixels.</summary>
	public Vector2 PositionToLocal( TextPosition position )
	{
		var row = Folding is null ? position.Line : Folding.ToVisual( position.Line );
		var x = GutterWidth + VisualColumn( position.Line, position.Column ) * CharWidth - ScrollX;
		return new Vector2( x, RowTop( row ) );
	}

	/// <summary>Converts a document position to screen pixels, for popups.</summary>
	public Vector2 PositionToScreen( TextPosition position ) => ToScreen( PositionToLocal( position ) );

	/// <summary>The screen rectangle of the caret, which is where a completion popup should anchor.</summary>
	public Rect CaretScreenRect
	{
		get
		{
			var local = PositionToLocal( CaretPosition );
			return new Rect( ToScreen( local ), new Vector2( Math.Max( 2f, CharWidth ), LineHeight ) );
		}
	}

	/// <summary>Converts widget-local pixels to a document position.</summary>
	public TextPosition LocalToPosition( Vector2 local )
	{
		var row = (int)MathF.Floor( (local.y + ScrollY) / Math.Max( 1f, LineHeight ) );
		row = Math.Clamp( row, 0, Math.Max( 0, VisualRowCount - 1 ) );

		var line = Folding is null ? row : Folding.ToDocument( row );
		line = Math.Clamp( line, 0, Document.LineCount - 1 );

		var visual = (int)MathF.Round( (local.x + ScrollX - GutterWidth) / Math.Max( 1f, CharWidth ) );
		visual = Math.Max( 0, visual );

		return new TextPosition( line, CharIndexFromVisual( line, visual ) );
	}

	/// <summary>The widest visual column in the document, cached per document version.</summary>
	public int MaxVisualColumns
	{
		get
		{
			if ( _maxColumnsVersion == Document.Version )
				return _maxColumns;

			var tab = Math.Max( 1, TabSize );
			var widest = 0;

			for ( var line = 0; line < Document.LineCount; line++ )
			{
				var text = Document.GetLine( line );
				var column = 0;

				for ( var i = 0; i < text.Length; i++ )
					column += text[i] == '\t' ? tab - (column % tab) : 1;

				if ( column > widest )
					widest = column;
			}

			_maxColumns = widest;
			_maxColumnsVersion = Document.Version;
			return widest;
		}
	}

	// ---- scrolling --------------------------------------------------------

	/// <summary>Rebuilds both scrollbar ranges from the current document and viewport.</summary>
	public void LayoutScrollbars()
	{
		if ( !IsValid || VerticalScrollbar is not { IsValid: true } || HorizontalScrollbar is not { IsValid: true } )
			return;

		UpdateGutterWidth();

		var viewHeight = Math.Max( 1f, Height );
		var viewWidth = Math.Max( 1f, Width );

		var contentHeight = VisualRowCount * LineHeight;
		var overscroll = LineHeight * 2f;

		VerticalScrollbar.Minimum = 0;
		VerticalScrollbar.Maximum = Math.Max( 0, (int)MathF.Ceiling( contentHeight - viewHeight + overscroll ) );
		VerticalScrollbar.SingleStep = Math.Max( 1, (int)LineHeight );
		VerticalScrollbar.PageStep = Math.Max( 1, (int)viewHeight );

		var contentWidth = GutterWidth + MaxVisualColumns * CharWidth + CharWidth * 4f;

		HorizontalScrollbar.Minimum = 0;
		HorizontalScrollbar.Maximum = Math.Max( 0, (int)MathF.Ceiling( contentWidth - viewWidth ) );
		HorizontalScrollbar.SingleStep = Math.Max( 1, (int)(CharWidth * 4f) );
		HorizontalScrollbar.PageStep = Math.Max( 1, (int)viewWidth );
	}

	/// <summary>Scrolls so the primary caret is comfortably inside the viewport.</summary>
	public void EnsureCaretVisible( int marginRows = 2 )
	{
		Reveal( TextRange.At( CaretPosition ), false, marginRows );
	}

	/// <summary>Scrolls a range into view, optionally selecting it.</summary>
	public void Reveal( TextRange range, bool select, int marginRows = 2 )
	{
		if ( !IsValid )
			return;

		var target = Document.Clamp( range.Normalized );

		Folding?.EnsureVisible( target.Start.Line );

		if ( select )
		{
			Selection.SetSingle( target.End, target.Start );
			Controller.Selection.Normalize();
		}

		var row = Folding is null ? target.Start.Line : Folding.ToVisual( target.Start.Line );
		var viewHeight = Math.Max( 1f, Height );

		var top = row * LineHeight;
		var bottom = top + LineHeight;

		var minimum = top - marginRows * LineHeight;
		var maximum = bottom + marginRows * LineHeight - viewHeight;

		var value = (float)VerticalScrollbar.Value;

		if ( value > minimum )
			value = minimum;

		if ( value < maximum )
			value = maximum;

		VerticalScrollbar.Value = Math.Clamp( (int)value, VerticalScrollbar.Minimum, VerticalScrollbar.Maximum );

		var x = VisualColumn( target.Start.Line, target.Start.Column ) * CharWidth;
		var viewWidth = Math.Max( 1f, Width ) - GutterWidth;
		var hValue = (float)HorizontalScrollbar.Value;

		if ( x - hValue < CharWidth * 4f )
			hValue = Math.Max( 0f, x - CharWidth * 8f );
		else if ( x - hValue > viewWidth - CharWidth * 4f )
			hValue = x - viewWidth + CharWidth * 8f;

		HorizontalScrollbar.Value = Math.Clamp( (int)hValue, HorizontalScrollbar.Minimum, HorizontalScrollbar.Maximum );

		Update();
	}

	/// <summary>Moves the caret to a one-based line and column and scrolls it into view.</summary>
	public void GoToLine( int oneBasedLine, int oneBasedColumn = 1 )
	{
		var line = Math.Clamp( oneBasedLine - 1, 0, Document.LineCount - 1 );
		var column = Math.Clamp( oneBasedColumn - 1, 0, Document.GetLineLength( line ) );

		Folding?.EnsureVisible( line );
		Controller.SetCaret( new TextPosition( line, column ), false );
		EnsureCaretVisible( 4 );
		Focus();
	}

	/// <summary>Scrolls by a number of rows without moving the caret.</summary>
	public void ScrollRows( int rows )
	{
		if ( VerticalScrollbar is not { IsValid: true } )
			return;

		var value = VerticalScrollbar.Value + (int)(rows * LineHeight);
		VerticalScrollbar.Value = Math.Clamp( value, VerticalScrollbar.Minimum, VerticalScrollbar.Maximum );
		Update();
	}

	protected override void OnResize()
	{
		base.OnResize();

		if ( !_metricsValid )
			RecomputeMetrics();

		LayoutScrollbars();
	}

	protected override void OnScrollChanged()
	{
		base.OnScrollChanged();
		Update();
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( e.HasCtrl )
		{
			FontSize += e.Delta > 0 ? 1 : -1;
			e.Accept();
			return;
		}

		if ( e.HasShift )
		{
			if ( HorizontalScrollbar is { IsValid: true } )
			{
				var value = HorizontalScrollbar.Value + (e.Delta > 0 ? -1 : 1) * (int)(CharWidth * 6f);
				HorizontalScrollbar.Value = Math.Clamp( value, HorizontalScrollbar.Minimum, HorizontalScrollbar.Maximum );
				Update();
			}

			e.Accept();
			return;
		}

		ScrollRows( e.Delta > 0 ? -3 : 3 );
		e.Accept();
	}

	// ---- painting ---------------------------------------------------------

	protected override void OnPaint()
	{
		if ( !_metricsValid )
			RecomputeMetrics();

		try
		{
			Renderer.Render();
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Prism.Text: code editor paint failed" );
		}
	}

	// ---- focus and shortcuts ----------------------------------------------

	/// <summary>Tab types a tab rather than moving focus.</summary>
	protected override bool FocusNext() => true;

	/// <summary>Shift+Tab outdents rather than moving focus.</summary>
	protected override bool FocusPrevious() => true;

	/// <summary>
	/// Steals every shortcut while focused. The editor's global shortcut table only exempts
	/// <c>LineEdit</c> and <c>TextEdit</c>, so without this every single-letter editor bind fires
	/// while the user types code.
	/// </summary>
	protected override void OnShortcutPressed( KeyEvent e )
	{
		e.Accepted = true;
	}

	protected override void OnFocus( FocusChangeReason reason )
	{
		base.OnFocus( reason );
		_caretOn = true;
		_sinceCaretMoved = 0;
		Update();
	}

	protected override void OnBlur( FocusChangeReason reason )
	{
		base.OnBlur( reason );
		Completion?.Dismiss();
		Update();
	}

	// ---- keyboard ---------------------------------------------------------

	protected override void OnKeyPress( KeyEvent e )
	{
		var info = new CodeKeyInfo( e.Key, e.Text, e.HasCtrl, e.HasShift, e.HasAlt );

		if ( Completion is not null && Completion.HandleKey( this, info ) )
		{
			e.Accepted = true;
			return;
		}

		if ( HandleKey( info ) )
		{
			e.Accepted = true;
			return;
		}

		PrismLog.Guard( "Prism.Text: host shortcut", () => UnhandledKey?.Invoke( this, info ) ?? false, false );

		// Never fall through to BaseScrollWidget: it eats Home, End, PageUp and PageDown.
		e.Accepted = true;
	}

	bool HandleKey( CodeKeyInfo key )
	{
		var pageRows = Math.Max( 1, VisibleRowCount - 2 );

		if ( key.Ctrl && !key.Alt )
		{
			switch ( key.Key )
			{
				case KeyCode.C: Controller.Copy(); return true;
				case KeyCode.X: Controller.Cut(); AfterEdit(); return true;
				case KeyCode.V: Controller.Paste(); AfterEdit(); return true;
				case KeyCode.A: Controller.SelectAll(); return true;
				case KeyCode.Z:
					if ( key.Shift ) Controller.PerformRedo();
					else Controller.PerformUndo();
					AfterEdit();
					return true;
				case KeyCode.Y: Controller.PerformRedo(); AfterEdit(); return true;
				case KeyCode.S: SaveRequested?.Invoke( this ); return true;
				case KeyCode.F: FindRequested?.Invoke( this, false ); return true;
				case KeyCode.H: FindRequested?.Invoke( this, true ); return true;
				case KeyCode.G: GoToLineRequested?.Invoke( this ); return true;
				case KeyCode.D:
					if ( key.Shift ) Controller.DuplicateSelection();
					else Controller.AddNextOccurrence();
					AfterEdit();
					return true;
				case KeyCode.L:
					if ( key.Shift ) Controller.SelectAllOccurrences();
					else Controller.ExpandSelectionToLines();
					AfterEdit();
					return true;
				case KeyCode.K:
					if ( key.Shift ) { Controller.DeleteLines(); AfterEdit(); return true; }
					break;
				case KeyCode.Slash: Controller.ToggleLineComment(); AfterEdit(); return true;
				case KeyCode.BracketLeft:
					if ( key.Shift ) { Folding?.CollapseAll(); Update(); }
					else Controller.Outdent();
					AfterEdit();
					return true;
				case KeyCode.BracketRight:
					if ( key.Shift ) { Folding?.ExpandAll(); Update(); }
					else Controller.Indent();
					AfterEdit();
					return true;
				case KeyCode.U:
				{
					Func<string, string> transform = key.Shift
						? static s => s.ToUpperInvariant()
						: static s => s.ToLowerInvariant();

					Controller.TransformSelection( transform, key.Shift ? "Upper Case" : "Lower Case" );
					AfterEdit();
					return true;
				}
				case KeyCode.Space:
					Completion?.RequestCompletion( this );
					return true;
				case KeyCode.Home: Controller.Move( CaretMove.DocumentStart, key.Shift ); EnsureCaretVisible(); return true;
				case KeyCode.End: Controller.Move( CaretMove.DocumentEnd, key.Shift ); EnsureCaretVisible(); return true;
				case KeyCode.Left: Controller.Move( CaretMove.WordLeft, key.Shift ); EnsureCaretVisible(); return true;
				case KeyCode.Right: Controller.Move( CaretMove.WordRight, key.Shift ); EnsureCaretVisible(); return true;
				case KeyCode.Up: ScrollRows( -1 ); return true;
				case KeyCode.Down: ScrollRows( 1 ); return true;
				case KeyCode.Backspace: Controller.Backspace( true ); AfterEdit(); return true;
				case KeyCode.Delete: Controller.DeleteForward( true ); AfterEdit(); return true;
			}
		}

		if ( key.Ctrl && key.Alt )
		{
			switch ( key.Key )
			{
				case KeyCode.Up: Controller.AddCaretVertically( -1 ); EnsureCaretVisible(); return true;
				case KeyCode.Down: Controller.AddCaretVertically( 1 ); EnsureCaretVisible(); return true;
			}
		}

		if ( key.Alt && !key.Ctrl )
		{
			switch ( key.Key )
			{
				case KeyCode.Up when !key.Shift: Controller.MoveLines( -1 ); AfterEdit(); return true;
				case KeyCode.Down when !key.Shift: Controller.MoveLines( 1 ); AfterEdit(); return true;
			}
		}

		switch ( key.Key )
		{
			case KeyCode.Left: Controller.Move( CaretMove.Left, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.Right: Controller.Move( CaretMove.Right, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.Up: Controller.Move( CaretMove.Up, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.Down: Controller.Move( CaretMove.Down, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.Home: Controller.Move( CaretMove.LineStart, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.End: Controller.Move( CaretMove.LineEnd, key.Shift ); EnsureCaretVisible(); return true;
			case KeyCode.PageUp: Controller.Move( CaretMove.PageUp, key.Shift, pageRows ); EnsureCaretVisible(); return true;
			case KeyCode.PageDown: Controller.Move( CaretMove.PageDown, key.Shift, pageRows ); EnsureCaretVisible(); return true;

			case KeyCode.Backspace: Controller.Backspace(); AfterEdit(); return true;
			case KeyCode.Delete: Controller.DeleteForward(); AfterEdit(); return true;

			case KeyCode.Return:
			case KeyCode.Enter:
				Controller.InsertNewLine();
				AfterEdit();
				return true;

			case KeyCode.Tab when !key.Ctrl:
				if ( key.Shift ) Controller.Outdent();
				else Controller.Indent();
				AfterEdit();
				return true;

			case KeyCode.Backtab when !key.Ctrl:
				Controller.Outdent();
				AfterEdit();
				return true;

			case KeyCode.Insert:
				Controller.Overwrite = !Controller.Overwrite;
				Update();
				return true;

			case KeyCode.Escape:
				if ( Completion is { IsOpen: true } )
				{
					Completion.Dismiss();
					return true;
				}

				return Controller.Escape();

			case KeyCode.F3:
				FindStepRequested?.Invoke( this, key.Shift ? -1 : 1 );
				return true;
		}

		if ( !string.IsNullOrEmpty( key.Text ) && !key.Ctrl && !key.Alt && key.Text[0] >= ' ' )
		{
			if ( Controller.InsertText( key.Text ) )
			{
				Completion?.OnTextInserted( this, key.Text );
				AfterEdit();
			}

			return true;
		}

		return false;
	}

	void AfterEdit()
	{
		EnsureCaretVisible();
		Update();
	}

	// ---- mouse ------------------------------------------------------------

	protected override void OnMousePress( MouseEvent e )
	{
		Focus();

		_lastMouseLocal = e.LocalPosition;

		if ( e.RightMouseButton )
		{
			e.Accepted = true;
			return;
		}

		if ( !e.LeftMouseButton && !e.MiddleMouseButton )
			return;

		var position = LocalToPosition( e.LocalPosition );

		if ( e.LocalPosition.x < GutterWidth )
		{
			HandleGutterPress( e, position );
			e.Accepted = true;
			return;
		}

		Completion?.Dismiss();

		if ( e.MiddleMouseButton || (e.HasAlt && e.HasShift) )
		{
			_boxDragging = true;
			_dragging = true;
			Selection.SetBox( Document, position, position );
			Update();
			e.Accepted = true;
			return;
		}

		var nearLastClick = _lastClickPosition.Line == position.Line
			&& Math.Abs( _lastClickPosition.Column - position.Column ) <= 2;

		var isRepeat = (e.IsDoubleClick || _sinceLastClick < 0.4f) && nearLastClick;

		_clickCount = isRepeat ? _clickCount + 1 : 1;
		_sinceLastClick = 0;
		_lastClickPosition = position;

		if ( _clickCount >= 3 )
		{
			Controller.SelectLineAt( position.Line, e.HasAlt );
			_dragging = false;
			e.Accepted = true;
			Update();
			return;
		}

		if ( _clickCount == 2 )
		{
			Controller.SelectWordAt( position, e.HasAlt );
			_dragging = true;
			e.Accepted = true;
			Update();
			return;
		}

		if ( e.HasAlt )
			Controller.ToggleCaret( position );
		else
			Controller.SetCaret( position, e.HasShift );

		_dragging = true;
		e.Accepted = true;
		Update();
	}

	void HandleGutterPress( MouseEvent e, TextPosition position )
	{
		var foldLeft = MarkerMarginWidth + LineNumberWidth;

		if ( ShowFoldMargin && e.LocalPosition.x >= foldLeft && Folding is not null )
		{
			if ( Folding.IsFoldStart( position.Line ) )
			{
				Folding.Toggle( position.Line );
				LayoutScrollbars();
				Update();
				return;
			}
		}

		_gutterDragging = true;
		_gutterDragAnchorLine = position.Line;
		Controller.SelectLineAt( position.Line, e.HasAlt );
		Update();
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		_lastMouseLocal = e.LocalPosition;

		Cursor = e.LocalPosition.x < GutterWidth ? CursorShape.Arrow : CursorShape.IBeam;

		if ( !_dragging && !_gutterDragging )
		{
			UpdateHoverTooltip( e.LocalPosition );
			return;
		}

		if ( (e.ButtonState & (MouseButtons.Left | MouseButtons.Middle)) == 0 )
		{
			_dragging = false;
			_gutterDragging = false;
			_boxDragging = false;
			return;
		}

		var position = LocalToPosition( e.LocalPosition );

		if ( _boxDragging )
		{
			Selection.SetBox( Document, Selection.BoxAnchor, position );
			Update();
			return;
		}

		if ( _gutterDragging )
		{
			var first = Math.Min( _gutterDragAnchorLine, position.Line );
			var last = Math.Max( _gutterDragAnchorLine, position.Line );

			var start = new TextPosition( first, 0 );
			var end = last < Document.LineCount - 1
				? new TextPosition( last + 1, 0 )
				: new TextPosition( last, Document.GetLineLength( last ) );

			Selection.SetSingle( position.Line >= _gutterDragAnchorLine ? end : start,
				position.Line >= _gutterDragAnchorLine ? start : end );

			_occurrencesDirty = true;
			Update();
			return;
		}

		if ( _clickCount == 2 )
		{
			var word = Controller.WordRangeAt( position );
			var primary = Selection.Primary;
			primary.Position = position >= primary.Anchor ? word.End : word.Start;
		}
		else
		{
			Selection.Primary.MoveTo( position, true );
		}

		Selection.Normalize();
		_occurrencesDirty = true;
		Update();
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		_dragging = false;
		_gutterDragging = false;
		_boxDragging = false;
		base.OnMouseReleased( e );
	}

	void UpdateHoverTooltip( Vector2 local )
	{
		if ( local.x < GutterWidth )
		{
			var gutterLine = LocalToPosition( local ).Line;
			var message = DiagnosticsOnLine( gutterLine );
			ToolTip = message;
			return;
		}

		var position = LocalToPosition( local );
		var diagnostic = DiagnosticAt( position );

		ToolTip = diagnostic is null
			? string.Empty
			: string.IsNullOrEmpty( diagnostic.Code ) ? diagnostic.Message : $"{diagnostic.Code}: {diagnostic.Message}";
	}

	string DiagnosticsOnLine( int line )
	{
		var builder = new StringBuilder();

		for ( var i = 0; i < _diagnostics.Count; i++ )
		{
			var diagnostic = _diagnostics[i];

			if ( diagnostic.Range.Min.Line != line )
				continue;

			if ( builder.Length > 0 )
				builder.Append( '\n' );

			builder.Append( diagnostic.Severity ).Append( ": " ).Append( diagnostic.Message );
		}

		return builder.ToString();
	}

	protected override void OnContextMenu( ContextMenuEvent e )
	{
		var menu = new Menu( this );

		menu.AddOption( "Cut", "content_cut", () => { Controller.Cut(); AfterEdit(); } ).Enabled = !ReadOnly;
		menu.AddOption( "Copy", "content_copy", () => Controller.Copy() );
		menu.AddOption( "Paste", "content_paste", () => { Controller.Paste(); AfterEdit(); } ).Enabled = !ReadOnly;
		menu.AddSeparator();
		menu.AddOption( "Undo", "undo", () => { Controller.PerformUndo(); AfterEdit(); } ).Enabled = !ReadOnly && Controller.Undo.CanUndo;
		menu.AddOption( "Redo", "redo", () => { Controller.PerformRedo(); AfterEdit(); } ).Enabled = !ReadOnly && Controller.Undo.CanRedo;
		menu.AddSeparator();
		menu.AddOption( "Select All", "select_all", () => Controller.SelectAll() );
		menu.AddOption( "Toggle Comment", "comment", () => { Controller.ToggleLineComment(); AfterEdit(); } ).Enabled = !ReadOnly;
		menu.AddSeparator();
		menu.AddOption( "Find…", "search", () => FindRequested?.Invoke( this, false ) );
		menu.AddOption( "Go To Line…", "my_location", () => GoToLineRequested?.Invoke( this ) );

		if ( Folding is not null )
		{
			menu.AddSeparator();
			menu.AddOption( "Fold All", "unfold_less", () => { Folding.CollapseAll(); LayoutScrollbars(); Update(); } );
			menu.AddOption( "Unfold All", "unfold_more", () => { Folding.ExpandAll(); LayoutScrollbars(); Update(); } );
		}

		menu.OpenAtCursor();
		e.Accepted = true;
	}

	// ---- occurrences ------------------------------------------------------

	void EnsureOccurrences()
	{
		if ( !_occurrencesDirty )
			return;

		_occurrencesDirty = false;
		_occurrences.Clear();

		if ( !HighlightOccurrences )
			return;

		var primary = Selection.Primary;
		var range = primary.HasSelection ? primary.Selection : Controller.WordRangeAt( primary.Position );

		if ( range.IsEmpty || !range.IsSingleLine )
			return;

		var needle = Document.GetText( range );

		if ( needle.Length < 2 || needle.Trim().Length == 0 )
			return;

		for ( var i = 0; i < needle.Length; i++ )
		{
			if ( char.IsWhiteSpace( needle[i] ) )
				return;
		}

		var first = Math.Max( 0, FirstVisibleRow - 40 );
		var last = Math.Min( VisualRowCount - 1, FirstVisibleRow + VisibleRowCount + 40 );

		for ( var row = first; row <= last; row++ )
		{
			var line = Folding is null ? row : Folding.ToDocument( row );
			var text = Document.GetLine( line );
			var index = 0;

			while ( index <= text.Length - needle.Length )
			{
				var found = text.IndexOf( needle, index, StringComparison.Ordinal );

				if ( found < 0 )
					break;

				var before = found == 0 || !TextEditController.IsWordChar( text[found - 1] );
				var after = found + needle.Length >= text.Length || !TextEditController.IsWordChar( text[found + needle.Length] );

				if ( before && after )
				{
					var match = new TextRange(
						new TextPosition( line, found ),
						new TextPosition( line, found + needle.Length ) );

					if ( !match.Equals( range ) )
						_occurrences.Add( match );
				}

				index = found + needle.Length;
			}
		}
	}

	// ---- completion support -----------------------------------------------

	/// <summary>The identifier immediately before the caret, which is what a completion list filters on.</summary>
	public string WordPrefixAtCaret => Controller.WordPrefixBefore( CaretPosition );

	/// <summary>The range a completion should replace: the identifier the caret sits in or just after.</summary>
	public TextRange CompletionReplaceRange
	{
		get
		{
			var caret = CaretPosition;
			var text = Document.GetLine( caret.Line );
			var start = Math.Clamp( caret.Column, 0, text.Length );

			while ( start > 0 && TextEditController.IsWordChar( text[start - 1] ) )
				start--;

			return new TextRange( new TextPosition( caret.Line, start ), caret );
		}
	}

	/// <summary>Whether the caret sits inside a comment or a string, where completion should stand down.</summary>
	public bool IsCaretInert => Lexer is not null && Lexer.IsInert( CaretPosition );

	/// <summary>The token class under the caret.</summary>
	public TokenKind CaretTokenKind => Lexer is null ? TokenKind.None : Lexer.KindAt( CaretPosition );

	/// <summary>
	/// Applies a completion: replaces <paramref name="range"/> with <paramref name="text"/> and leaves
	/// the caret at the end of the inserted text, in a single undo step.
	/// <para>
	/// <paramref name="caretBack"/> pulls the caret back that many characters from the end, which is how
	/// a multi-line skeleton lands the caret on the line worth editing instead of past its closing brace.
	/// </para>
	/// </summary>
	public bool CommitCompletion( TextRange range, string text, int caretBack = 0 )
	{
		if ( ReadOnly )
			return false;

		var start = range.Normalized.Start;

		if ( !Controller.ReplaceRange( range, text, "Completion" ) )
			return false;

		if ( caretBack > 0 && !string.IsNullOrEmpty( text ) )
			Controller.SetCaret( Advance( start, text, text.Length - caretBack ), false );

		EnsureCaretVisible();
		Update();
		return true;
	}

	/// <summary>The position <paramref name="count"/> characters into <paramref name="text"/>, counting line breaks.</summary>
	static TextPosition Advance( TextPosition from, string text, int count )
	{
		var line = from.Line;
		var column = from.Column;

		for ( var i = 0; i < count && i < text.Length; i++ )
		{
			var c = text[i];

			if ( c == '\r' || c == '\n' )
			{
				if ( c == '\r' && i + 1 < count && i + 1 < text.Length && text[i + 1] == '\n' )
					i++;

				line++;
				column = 0;
				continue;
			}

			column++;
		}

		return new TextPosition( line, column );
	}

	/// <summary>Types text at every caret exactly as if the user had, honouring auto-close and undo coalescing.</summary>
	public bool InsertAtCaret( string text )
	{
		if ( !Controller.InsertText( text, false ) )
			return false;

		AfterEdit();
		return true;
	}

	// ---- frame ------------------------------------------------------------

	[EditorEvent.Frame]
	void PrismEditorFrame()
	{
		if ( !IsValid || !Visible )
			return;

		var on = !IsFocused || _sinceCaretMoved < 0.5f || (int)(_sinceCaretMoved / 0.53f) % 2 == 0;

		if ( on != _caretOn )
		{
			_caretOn = on;
			Update();
		}

		if ( Lexer is { IsDirty: true } && Lexer.Sync( 400 ) > 0 )
		{
			// Folding depends on token classes, so refresh it once lexing has converged.
			if ( !Lexer.IsDirty )
				Folding?.Invalidate();

			Update();
		}

		if ( _dragging && (_lastMouseLocal.y < 0f || _lastMouseLocal.y > Height) )
		{
			ScrollRows( _lastMouseLocal.y < 0f ? -1 : 1 );

			var position = LocalToPosition( _lastMouseLocal );

			if ( _boxDragging )
				Selection.SetBox( Document, Selection.BoxAnchor, position );
			else
				Selection.Primary.MoveTo( position, true );

			Update();
		}

		if ( _settlePending && _sinceEdit > PrismConstants.TextDebounceMs / 1000f )
		{
			_settlePending = false;

			// Folding is invalidated lazily, so refresh the scrollbar once editing settles.
			if ( Folding is { HasCollapsed: true } )
				LayoutScrollbars();

			PrismLog.Guard( "Prism.Text: settled", () => TextSettled?.Invoke( this ) );
		}
	}

	/// <summary>
	/// Replaces the whole buffer, optionally keeping the viewport where it was. Used by read-only
	/// panels that re-push regenerated text and must not lose the reader's place.
	/// </summary>
	public void SetText( string text, string language = null, bool preserveViewport = true )
	{
		var scrollY = ScrollY;
		var scrollX = ScrollX;
		var caret = CaretPosition;

		if ( !string.IsNullOrEmpty( language ) )
			Language = language;

		Document.Text = text ?? string.Empty;
		Document.MarkSaved();
		Controller.Undo.Clear();
		Lexer?.Invalidate();
		Folding?.Invalidate();

		if ( preserveViewport )
		{
			Selection.SetSingle( Document.Clamp( caret ) );
			LayoutScrollbars();

			if ( VerticalScrollbar is { IsValid: true } )
				VerticalScrollbar.Value = Math.Clamp( (int)scrollY, VerticalScrollbar.Minimum, VerticalScrollbar.Maximum );

			if ( HorizontalScrollbar is { IsValid: true } )
				HorizontalScrollbar.Value = Math.Clamp( (int)scrollX, HorizontalScrollbar.Minimum, HorizontalScrollbar.Maximum );
		}
		else
		{
			Selection.SetSingle( TextPosition.Zero );
			LayoutScrollbars();
		}

		Update();
	}

	/// <summary>Detaches every model when the native widget goes away.</summary>
	public override void OnDestroyed()
	{
		Teardown();
		base.OnDestroyed();
	}

	/// <summary>Detaches every model from the document. Safe to call more than once.</summary>
	public void Teardown()
	{
		// PrismTheme is static and outlives the widget, so a handler left behind pins a destroyed editor
		// and its whole document for the rest of the session.
		PrismTheme.Changed -= OnThemeChanged;

		if ( Document is not null )
			Document.Changed -= OnDocumentChanged;

		if ( Controller is not null )
		{
			Controller.Changed -= OnControllerChanged;
			Controller.CaretChanged -= OnControllerCaretChanged;
		}

		Lexer?.Detach();
		Folding?.Detach();
		Completion?.Dismiss();
	}
}