using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Numerics; using Robust.Client.Graphics; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Collections; using Robust.Shared.Console; using Robust.Shared.Input; using Robust.Shared.IoC; using Robust.Shared.Maths; using Robust.Shared.Timing; using Robust.Shared.Utility; using Robust.Shared.ViewVariables; namespace Robust.Client.UserInterface.Controls; /// /// A multi-line text editing control. /// /// /// /// This control uses a to performantly manipulate editable text. /// /// /// Text selection and cursor positions are tracked with two values: a "selection start" and a "cursor position". /// When you do not have a selection (i.e. just a lone cursor) these values are at the same position. /// If you have an active selection, the cursor position is the part of the selection you can move around /// (via mouse or shift+left/right), while the start is the part that stays in place always. /// /// /// Positions inside this control are all in chars. /// Cursor positions and such should never be inside a surrogate pair, however. /// /// public sealed partial class TextEdit : Control { [Dependency] private IClipboardManager _clipboard = null!; // @formatter:off public const string StylePropertyCursorColor = "cursor-color"; public const string StylePropertySelectionColor = "selection-color"; public const string StylePseudoClassNotEditable = "notEditable"; public const string StylePseudoClassPlaceholder = "placeholder"; // @formatter:on private readonly RenderBox _renderBox; private readonly VScrollBar _scrollBar; private CursorPos _cursorPosition; private CursorPos _selectionStart; // Cached last horizontal cursor position for vertical cursor movement. // Conceptually, moving the cursor up/down across "keeps" the horizontal position as much as possible. // This allows it to remain in position even when the cursor is moving past "valleys" of short lines. private float? _horizontalCursorPos; // Stores the positions of all line breaks inside the edited text. // The format is a list of all indices into the text rope where a line break should occur. // These line breaks are "before" the indexed character. So if I have the string "AB", with a line break at index 1, // that means the line break is BETWEEN A and B. // // Line breaks come from either explicit newlines (\n) or from word-wrapping behavior. // It should be noted that in the case of newlines, the newline character is actually considered "on the next line". // This also has implications for cursor bias, see below. private ValueList _lineBreaks; private Rope.Node _textRope = Rope.Leaf.Empty; private Rope.Node? _placeholder; private bool _lineUpdateQueued; private bool _editable = true; // Uncommitted IME positions are stored directly in the text rope. // This field tracks the start position thereof, and how long it is. // The intent is that the text is cut from the rope again if the composition gets cancelled or edited. private (CursorPos start, int length)? _imeData; // Yay fancy blink animation!!!! private TextEditShared.CursorBlink _blink; // State for click-hold text selection. private bool _mouseSelectingText; private Vector2 _lastMouseSelectPos; // Debug overlay stuff. internal bool DebugOverlay; private Vector2? _lastDebugMousePos; public event Action? OnTextChanged; public TextEdit() { IoCManager.InjectDependencies(this); AddChild(_renderBox = new RenderBox(this)); AddChild(_scrollBar = new VScrollBar { HorizontalAlignment = HAlignment.Right }); CanKeyboardFocus = true; KeyboardFocusOnClick = true; MouseFilter = MouseFilterMode.Stop; DefaultCursorShape = Editable ? CursorShape.IBeam : CursorShape.NotAllowed; } /// /// The current position of the cursor in the text rope. /// /// /// See the class remarks for how text selection works in this control. /// /// /// Thrown if the given position would put the cursor inside a surrogate pair. /// public CursorPos CursorPosition { get => _cursorPosition; set { var clamped = MathHelper.Clamp(value.Index, 0, TextLength); if (TextLength != 0 && TextLength != clamped && !Rope.TryGetRuneAt(TextRope, clamped, out _)) throw new ArgumentException("Cannot set cursor inside surrogate pair."); _cursorPosition = value with { Index = clamped }; _selectionStart = _cursorPosition; } } /// /// The start position of the selection in the text rope. /// /// /// See the class remarks for how text selection works in this control. /// /// /// Thrown if the given position would put the cursor inside a surrogate pair. /// public CursorPos SelectionStart { get => _selectionStart; set { var clamped = MathHelper.Clamp(value.Index, 0, TextLength); if (TextLength != 0 && TextLength != clamped && !Rope.TryGetRuneAt(TextRope, clamped, out _)) throw new ArgumentException("Cannot set cursor inside surrogate pair."); _selectionStart = value with { Index = clamped }; } } /// /// The text rope that can be viewed and edited. /// public Rope.Node TextRope { get => _textRope; set { _textRope = value; QueueLineBreakUpdate(); UpdatePseudoClass(); } } /// /// True to allow editing by the user. False to make it read-only. /// public bool Editable { get => _editable; set { _editable = value; DefaultCursorShape = _editable ? CursorShape.IBeam : CursorShape.NotAllowed; UpdatePseudoClass(); } } /// /// The lower (in string index terms) end of the active text selection. /// /// /// Confusingly, because text is read top-to-bottom, "lower" is actually higher up on your monitor. /// public CursorPos SelectionLower => CursorPos.Min(_selectionStart, _cursorPosition); /// /// The upper (in string index terms) end of the active text selection. /// /// /// Confusingly, because text is read top-to-bottom, "upper" is actually lower down on your monitor. /// public CursorPos SelectionUpper => CursorPos.Max(_selectionStart, _cursorPosition); /// /// The amount of chars selected by the active selection. /// public int SelectionLength => Math.Abs(_selectionStart.Index - _cursorPosition.Index); // TODO: cache public int TextLength => (int)Rope.CalcTotalLength(TextRope); public System.Range SelectionRange => (SelectionLower.Index)..(SelectionUpper.Index); /// /// A placeholder text to be displayed when no actual text is entered. /// /// /// /// This isn't editable by the user, but to make the internals simpler, it is still exposed as a rope. /// /// /// When set to null, the placeholder style pseudo-class will not be applied when the actual text content is empty. /// If set to an empty text rope this does happen, but obviously no text is displayed either way. /// /// [ViewVariables(VVAccess.ReadWrite)] public Rope.Node? Placeholder { get => _placeholder; set { _placeholder = value; UpdatePseudoClass(); QueueLineBreakUpdate(); } } private bool IsPlaceholderVisible => Rope.IsNullOrEmpty(_textRope) && _placeholder != null; // Table used by cursor movement system, see below. private static readonly FrozenDictionary MoveTypeMap = new Dictionary() { // @formatter:off { EngineKeyFunctions.TextCursorLeft, MoveType.Left }, { EngineKeyFunctions.TextCursorRight, MoveType.Right }, { EngineKeyFunctions.TextCursorUp, MoveType.Up }, { EngineKeyFunctions.TextCursorDown, MoveType.Down }, { EngineKeyFunctions.TextCursorWordLeft, MoveType.LeftWord }, { EngineKeyFunctions.TextCursorWordRight, MoveType.RightWord }, { EngineKeyFunctions.TextCursorBegin, MoveType.BeginOfLine }, { EngineKeyFunctions.TextCursorEnd, MoveType.EndOfLine }, { EngineKeyFunctions.TextCursorSelectLeft, MoveType.Left | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectRight, MoveType.Right | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectUp, MoveType.Up | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectDown, MoveType.Down | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectWordLeft, MoveType.LeftWord | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectWordRight, MoveType.RightWord | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectBegin, MoveType.BeginOfLine | MoveType.SelectFlag }, { EngineKeyFunctions.TextCursorSelectEnd, MoveType.EndOfLine | MoveType.SelectFlag }, // @formatter:on }.ToFrozenDictionary(); protected internal override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); if (args.Handled) return; var doCursorVisible = true; if (MoveTypeMap.TryGetValue(args.Function, out var moveType)) { // Most movement operations like normal vs word-bound work the same with or without an active text selection. // To allow sharing this code, we map key functions to a flag that defines the actual operation, // making code reuse easy. // Calculate actual new position. var selectFlag = (moveType & MoveType.SelectFlag) != 0; var newPos = CalcCursorMove(moveType & MoveType.ActionMask, selectFlag, out var keepH); _cursorPosition = newPos; // If not selecting text, keep selection start at cursor (possibly breaking an active selection). if (!selectFlag) _selectionStart = _cursorPosition; if (!keepH) InvalidateHorizontalCursorPos(); args.Handle(); } else if (args.Function == EngineKeyFunctions.TextBackspace) { if (Editable) { var changed = false; var oldText = _textRope; var cursor = _cursorPosition; var selectStart = _selectionStart; if (_selectionStart != _cursorPosition) { TextRope = Rope.Delete(oldText, SelectionLower.Index, SelectionLength); _cursorPosition = SelectionLower; changed = true; } else if (_cursorPosition.Index != 0) { var remPos = _cursorPosition.Index - 1; var remAmt = 1; // If this is a low surrogate remove two chars to remove the whole pair. if (char.IsLowSurrogate(Rope.Index(oldText, remPos))) { remPos -= 1; remAmt = 2; } TextRope = Rope.Delete(oldText, remPos, remAmt); _cursorPosition.Index -= remAmt; changed = true; } if (changed) { _selectionStart = _cursorPosition; OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); // _updatePseudoClass(); // OnBackspace?.Invoke(new LineEditBackspaceEventArgs(oldText, _text, cursor, selectStart)); } InvalidateHorizontalCursorPos(); args.Handle(); } } else if (args.Function == EngineKeyFunctions.TextDelete) { if (Editable) { var changed = false; if (_selectionStart != _cursorPosition) { TextRope = Rope.Delete(TextRope, SelectionLower.Index, SelectionLength); _cursorPosition = SelectionLower; changed = true; } else if (_cursorPosition.Index < TextLength) { var remAmt = 1; if (char.IsHighSurrogate(Rope.Index(TextRope, _cursorPosition.Index))) remAmt = 2; TextRope = Rope.Delete(TextRope, _cursorPosition.Index, remAmt); changed = true; } if (changed) { _selectionStart = _cursorPosition; OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); // _updatePseudoClass(); } InvalidateHorizontalCursorPos(); args.Handle(); } } else if (args.Function == EngineKeyFunctions.TextWordBackspace) { if (Editable) { var changed = false; // If there is a selection, we just delete the selection. Otherwise we delete the previous word if (_selectionStart != _cursorPosition) { TextRope = Rope.Delete(TextRope, SelectionLower.Index, SelectionLength); _cursorPosition = SelectionLower; changed = true; } else if (_cursorPosition.Index < TextLength) { var runes = Rope.EnumerateRunesReverse(TextRope, _cursorPosition.Index); int remAmt = -TextEditShared.PrevWordPosition(runes.GetEnumerator()); TextRope = Rope.Delete(TextRope, _cursorPosition.Index - remAmt, remAmt); _cursorPosition.Index -= remAmt; changed = true; } if (changed) { _selectionStart = _cursorPosition; OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); } InvalidateHorizontalCursorPos(); args.Handle(); } } else if (args.Function == EngineKeyFunctions.TextWordDelete) { if (Editable) { var changed = false; // If there is a selection, we just delete the selection. Otherwise we delete the next word if (_selectionStart != _cursorPosition) { TextRope = Rope.Delete(TextRope, SelectionLower.Index, SelectionLength); _cursorPosition = SelectionLower; changed = true; } else if (_cursorPosition.Index < TextLength) { var runes = Rope.EnumerateRunes(TextRope, _cursorPosition.Index); int endWord = _cursorPosition.Index + TextEditShared.EndWordPosition(runes.GetEnumerator()); TextRope = Rope.Delete(TextRope, _cursorPosition.Index, endWord - _cursorPosition.Index); changed = true; } if (changed) { _selectionStart = _cursorPosition; OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); } InvalidateHorizontalCursorPos(); args.Handle(); } } else if (args.Function == EngineKeyFunctions.TextNewline) { InsertAtCursor("\n"); InvalidateHorizontalCursorPos(); args.Handle(); } else if (args.Function == EngineKeyFunctions.TextSelectAll) { _cursorPosition = new CursorPos(TextLength, LineBreakBias.Bottom); _selectionStart = new CursorPos(0, LineBreakBias.Top); InvalidateHorizontalCursorPos(); args.Handle(); } else if (args.Function == EngineKeyFunctions.UIClick || args.Function == EngineKeyFunctions.TextCursorSelect) { _mouseSelectingText = true; _lastMouseSelectPos = args.RelativePosition; // Find closest cursor position under mouse. var index = GetIndexAtPos(args.RelativePosition); _cursorPosition = index; if (args.Function != EngineKeyFunctions.TextCursorSelect) { _selectionStart = _cursorPosition; } InvalidateHorizontalCursorPos(); args.Handle(); doCursorVisible = false; } else if (args.Function == EngineKeyFunctions.TextCopy) { var range = SelectionRange; var text = Rope.CollapseSubstring(TextRope, range); if (text.Length != 0) { _clipboard.SetText(text); } args.Handle(); } else if (args.Function == EngineKeyFunctions.TextCut) { if (Editable || SelectionLower != SelectionUpper) { var range = SelectionRange; var text = Rope.CollapseSubstring(TextRope, range); if (text.Length != 0) { _clipboard.SetText(text); } InsertAtCursor(""); } args.Handle(); } else if (args.Function == EngineKeyFunctions.TextPaste) { if (Editable) { async void DoPaste() { // Happens asynchronously, be aware var text = await _clipboard.GetText(); InsertAtCursor(text); EnsureCursorVisible(); } DoPaste(); } args.Handle(); doCursorVisible = false; } else if (args.Function == EngineKeyFunctions.TextReleaseFocus) { ReleaseKeyboardFocus(); args.Handle(); return; } if (args.Handled) { // Reset this so the cursor is always visible immediately after a keybind is pressed. _blink.Reset(); if (doCursorVisible) EnsureCursorVisible(); } } private void CacheHorizontalCursorPos(CursorPos pos) { EnsureLineBreaksUpdated(); if (_horizontalCursorPos != null) return; _horizontalCursorPos = GetHorizontalPositionAtIndex(pos); } /// /// Calculate the position the cursor should move to with a certain move. /// /// /// This method is not pure: while it does not modify the actual cursor position yet, /// only calculating the next position, it still calls CacheHorizontalCursorPos manually. /// /// The type of move the cursor is doing. /// Whether a selection is being expanded. /// /// Whether the cached horizontal cursor position must be kept instead of being invalidates. /// /// The new position of the cursor in the text. private CursorPos CalcCursorMove(MoveType type, bool select, out bool keepHorizontalCursorPos) { DebugTools.Assert(BitOperations.PopCount((uint)type) == 1, "Only a single movement bit may be set in the type"); keepHorizontalCursorPos = false; var breakingSelection = _selectionStart != _cursorPosition && !select; switch (type) { case MoveType.Left: { if (breakingSelection) { // If we're breaking an active selection, move to the lower side of it. return SelectionLower; } var (_, lineStart, _) = GetLineForCursorPos(_cursorPosition); if (_cursorPosition.Bias == LineBreakBias.Bottom && _cursorPosition.Index == lineStart) { // Swap cursor bias around to make the cursor appear at the end of the previous line. // This maintains the index, it's the same position in the text return _cursorPosition with { Bias = LineBreakBias.Top }; } var newPos = CursorShiftedLeft(); // Explicit newlines work kinda funny with bias, so keep it at top there. var bias = _cursorPosition.Index == TextLength || Rope.Index(TextRope, newPos) == '\n' ? LineBreakBias.Top : LineBreakBias.Bottom; return new CursorPos(newPos, bias); } case MoveType.Right: { if (breakingSelection) { return SelectionUpper; } var (_, _, lineEnd) = GetLineForCursorPos(_cursorPosition); // Explicit newlines work kinda funny with bias, so keep it at top there. if (_cursorPosition.Bias == LineBreakBias.Top && _cursorPosition.Index == lineEnd && _cursorPosition.Index != TextLength && Rope.Index(TextRope, _cursorPosition.Index) != '\n') { // Swap cursor bias around to make the cursor appear at the start of the next line. // This maintains the index, it's the same position in the text. return _cursorPosition with { Bias = LineBreakBias.Bottom }; } return new CursorPos(CursorShiftedRight(), LineBreakBias.Top); } case MoveType.LeftWord: { var runes = Rope.EnumerateRunesReverse(TextRope, _cursorPosition.Index); var pos = _cursorPosition.Index + TextEditShared.PrevWordPosition(runes.GetEnumerator()); return new CursorPos(pos, LineBreakBias.Bottom); } case MoveType.RightWord: { var runes = Rope.EnumerateRunes(TextRope, _cursorPosition.Index); var pos = _cursorPosition.Index + TextEditShared.EndWordPosition(runes.GetEnumerator()); return new CursorPos(pos, LineBreakBias.Bottom); } case MoveType.Up: { var cursor = _cursorPosition; if (breakingSelection) { // If we're in a selection, we move from the selection LOWER upwards, not the cursor position. InvalidateHorizontalCursorPos(); cursor = SelectionLower; } CacheHorizontalCursorPos(cursor); DebugTools.Assert(_horizontalCursorPos.HasValue, "Horizontal cursor pos must be cached!"); var (line, _, _) = GetLineForCursorPos(cursor); if (line == 0) { // We're on the top line already, move to the start of it instead. return new CursorPos(0, LineBreakBias.Top); } keepHorizontalCursorPos = true; return GetIndexAtHorizontalPos(line - 1, _horizontalCursorPos!.Value); } case MoveType.Down: { var cursor = _cursorPosition; if (breakingSelection) { // If we're in a selection, we move from the selection LOWER upwards, not the cursor position. InvalidateHorizontalCursorPos(); cursor = SelectionUpper; } CacheHorizontalCursorPos(cursor); DebugTools.Assert(_horizontalCursorPos.HasValue, "Horizontal cursor pos must be cached!"); var (line, _, _) = GetLineForCursorPos(cursor); if (line == _lineBreaks.Count) { // On the last line already, move to the end of it. return new CursorPos(TextLength, LineBreakBias.Top); } keepHorizontalCursorPos = true; return GetIndexAtHorizontalPos(line + 1, _horizontalCursorPos!.Value); } case MoveType.BeginOfLine: { var (_, lineStart, _) = GetLineForCursorPos(_cursorPosition); if (Rope.Index(TextRope, lineStart) == '\n') lineStart += 1; return new CursorPos(lineStart, LineBreakBias.Bottom); } case MoveType.EndOfLine: { var (_, _, lineEnd) = GetLineForCursorPos(_cursorPosition); return new CursorPos(lineEnd, LineBreakBias.Top); } default: throw new ArgumentOutOfRangeException(nameof(type), type, null); } } [Pure] private int CursorShiftedLeft() { if (_cursorPosition.Index == 0) return _cursorPosition.Index; return (int)Rope.RuneShiftLeft(_cursorPosition.Index, _textRope); } [Pure] private int CursorShiftedRight() { if (_cursorPosition.Index == TextLength) return _cursorPosition.Index; return (int)Rope.RuneShiftRight(_cursorPosition.Index, _textRope); } protected internal override void KeyBindUp(GUIBoundKeyEventArgs args) { base.KeyBindUp(args); if (args.Function == EngineKeyFunctions.UIClick || args.Function == EngineKeyFunctions.TextCursorSelect) { _mouseSelectingText = false; } } private void InvalidateHorizontalCursorPos() { _horizontalCursorPos = null; } protected internal override void TextEntered(GUITextEnteredEventArgs args) { base.TextEntered(args); if (!Editable) return; InsertAtCursor(args.Text); _blink.Reset(); EnsureCursorVisible(); // OnTextTyped?.Invoke(args); } protected internal override void TextEditing(GUITextEditingEventArgs args) { base.TextEditing(args); if (!Editable) return; var ev = args.Event; var startChars = ev.GetStartChars(); // Just break an active composition and build it anew to handle in-progress ones. AbortIme(); if (ev.Text != "") { if (_selectionStart != _cursorPosition) { // Delete active text selection. InsertAtCursor(""); } var startPos = _cursorPosition; TextRope = Rope.Insert(TextRope, startPos.Index, ev.Text); OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); _selectionStart = _cursorPosition = new CursorPos(startPos.Index + startChars, LineBreakBias.Top); _imeData = (startPos, ev.Text.Length); } EnsureCursorVisible(); } private void AbortIme(bool delete = true) { if (!_imeData.HasValue) return; if (delete) { var (imeStart, imeLength) = _imeData.Value; TextRope = Rope.Delete(TextRope, imeStart.Index, imeLength); _selectionStart = _cursorPosition = imeStart; } _imeData = null; } protected override Vector2 ArrangeOverride(Vector2 finalSize) { var size = base.ArrangeOverride(finalSize); var renderBoxSize = _renderBox.Size; _scrollBar.Page = renderBoxSize.Y * UIScale; UpdateLineBreaks((int)(renderBoxSize.X * UIScale)); return size; } protected override void FrameUpdate(FrameEventArgs args) { base.FrameUpdate(args); EnsureLineBreaksUpdated(); _blink.FrameUpdate(args); if (_mouseSelectingText) { var contentBox = PixelSizeBox; var index = GetIndexAtPos(_lastMouseSelectPos); _cursorPosition = index; // Only move scrollbar when the cursor is dragging above/below the text control. if (_lastMouseSelectPos.Y < contentBox.Top) { EnsureCursorVisible(); } else if (_lastMouseSelectPos.Y > contentBox.Bottom) { EnsureCursorVisible(); } } } [Pure] private Font GetFont() { return StylePropertyDefault("font", UserInterfaceManager.ThemeDefaults.DefaultFont); } [Pure] private Color GetFontColor() { return StylePropertyDefault("font-color", Color.White); } internal void QueueLineBreakUpdate() { _lineUpdateQueued = true; } private void EnsureLineBreaksUpdated() { if (_lineUpdateQueued) UpdateLineBreaks(PixelWidth); } public void InsertAtCursor(string text) { // Strip newlines. var lower = SelectionLower.Index; var upper = SelectionUpper.Index; TextRope = Rope.ReplaceSubstring(TextRope, lower, upper - lower, text); OnTextChanged?.Invoke(new TextEditEventArgs(this, _textRope)); _selectionStart = _cursorPosition = new CursorPos(lower + text.Length, LineBreakBias.Top); // OnTextChanged?.Invoke(new LineEditEventArgs(this, _text)); // _updatePseudoClass(); } private void UpdateLineBreaks(int pixelWidth) { _lineBreaks.Clear(); InvalidateHorizontalCursorPos(); var font = GetFont(); var scale = UIScale; var wordWrap = new WordWrap(pixelWidth); int? breakLine; foreach (var rune in Rope.EnumerateRunes(GetDisplayRope())) { wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip); CheckLineBreak(breakLine); CheckLineBreak(breakNewLine); if (skip) continue; // Uh just skip unknown characters I guess. if (!font.TryGetCharMetrics(rune, scale, out var metrics)) continue; wordWrap.NextMetrics(metrics, out breakLine, out var abort); CheckLineBreak(breakLine); if (abort) return; } wordWrap.FinalizeText(out breakLine); CheckLineBreak(breakLine); void CheckLineBreak(int? line) { if (line is { } l) { _lineBreaks.Add(l); } } // Update scroll bar max size. var lineCount = GetLineCount(); _scrollBar.MaxValue = Math.Max(_scrollBar.Page, lineCount * font.GetLineHeight(scale)); _lineUpdateQueued = false; } /// /// Get the rope of text actually being displayed. This may be the placeholder. /// private Rope.Node GetDisplayRope() { if (!Rope.IsNullOrEmpty(_textRope)) return _textRope; return _placeholder ?? Rope.Leaf.Empty; } private int GetLineCount() { return _lineBreaks.Count + 1; } private CursorPos GetIndexAtPos(Vector2 position) { EnsureLineBreaksUpdated(); var clickPos = position * UIScale; clickPos.Y += _scrollBar.Value; var font = GetFont(); var lineHeight = font.GetLineHeight(UIScale); var lineIndex = (int)(clickPos.Y / lineHeight); return GetIndexAtHorizontalPos(lineIndex, position.X); } private CursorPos GetIndexAtHorizontalPos(int line, float horizontalPos) { // If the placeholder is visible, this function does not return correct results because it looks at TextRope, // but _lineBreaks is configured for the display rope. // Bail out early in this case, the function is not currently used in any situation in any location // where something else is desired if the placeholder is visible. if (IsPlaceholderVisible) return default; var contentBox = PixelSizeBox; var font = GetFont(); var uiScale = UIScale; horizontalPos *= uiScale; (int, int) FindVerticalLine() { // Step one: find the vertical line containing the mouse position. if (line > _lineBreaks.Count) { // Below the last line, return the far end of the last line then. return (TextLength, TextLength); } if (line < 0) { // Above the first line, clamp. return (0, 0); } return ( line == 0 ? 0 : _lineBreaks[line - 1], _lineBreaks.Count == line ? TextLength : _lineBreaks[line] ); } // textIdx = start index on the vertical line we're on. // breakIdx = where the next line starts. var (textIdx, breakIdx) = FindVerticalLine(); var chrPosX = 0f; var lastChrPosX = 0f; var index = textIdx; foreach (var rune in Rope.EnumerateRunes(TextRope, textIdx)) { if (index >= breakIdx) { break; } if (!font.TryGetCharMetrics(rune, uiScale, out var metrics)) { index += rune.Utf16SequenceLength; continue; } if (chrPosX > horizontalPos) { break; } lastChrPosX = chrPosX; chrPosX += metrics.Advance; index += rune.Utf16SequenceLength; if (chrPosX > contentBox.Right) { break; } } // Distance between the right side of the glyph overlapping the mouse and the mouse. var distanceRight = chrPosX - horizontalPos; // Same but left side. var distanceLeft = horizontalPos - lastChrPosX; // If the mouse is closer to the left of the glyph we lower the index one, so we select before that glyph. if (index > 0 && distanceRight > distanceLeft) { index = (int)Rope.RuneShiftLeft(index, TextRope); } return new CursorPos(index, index == textIdx ? LineBreakBias.Bottom : LineBreakBias.Top); } private float GetHorizontalPositionAtIndex(CursorPos pos) { EnsureLineBreaksUpdated(); var hPos = 0; var font = GetFont(); var uiScale = UIScale; var (_, lineStart, _) = GetLineForCursorPos(pos); using var runeEnumerator = Rope.EnumerateRunes(TextRope, lineStart).GetEnumerator(); var i = lineStart; while (true) { if (i >= pos.Index) break; if (!runeEnumerator.MoveNext()) break; var rune = runeEnumerator.Current; if (font.TryGetCharMetrics(rune, uiScale, out var metrics)) hPos += metrics.Advance; i += rune.Utf16SequenceLength; } return hPos / uiScale; } private (int lineIdx, int lineStart, int lineEnd) GetLineForCursorPos(CursorPos pos) { DebugTools.Assert(pos.Index >= 0); EnsureLineBreaksUpdated(); if (_lineBreaks.Count == 0) return (0, 0, TextLength); int i; for (i = 0; i < _lineBreaks.Count; i++) { var lineIdx = _lineBreaks[i]; if (pos.Bias == LineBreakBias.Bottom ? (lineIdx > pos.Index) : (lineIdx >= pos.Index)) { if (i == 0) { // First line return (0, 0, lineIdx); } return (i, _lineBreaks[i - 1], lineIdx); } } // Position is on last line. return (_lineBreaks.Count, _lineBreaks[^1], TextLength); } private int GetStartOfLine(int lineIndex) { if (lineIndex <= 0) { // First line: start of text return 0; } if (lineIndex > _lineBreaks.Count) { // Past the last line: just put it at text end so nothing happens I guess. return TextLength; } return _lineBreaks[lineIndex - 1]; } protected internal override void MouseExited() { base.MouseExited(); _lastDebugMousePos = null; } protected internal override void MouseMove(GUIMouseMoveEventArgs args) { base.MouseMove(args); _lastDebugMousePos = args.RelativePosition; _lastMouseSelectPos = args.RelativePosition; } protected internal override void MouseWheel(GUIMouseWheelEventArgs args) { base.MouseWheel(args); if (MathHelper.CloseToPercent(0, args.Delta.Y)) return; _scrollBar.ValueTarget -= GetScrollSpeed() * args.Delta.Y; } [Pure] private float GetScrollSpeed() { return OutputPanel.GetScrollSpeed(GetFont(), UIScale); } private void EnsureCursorVisible() { EnsureLineBreaksUpdated(); var font = GetFont(); var scrollOffset = _scrollBar.Value; var (cursorLine, _, _) = GetLineForCursorPos(_cursorPosition); var cursorMargin = font.GetLineHeight(UIScale) * 1.5f; var (lineT, lineB) = GetBoundsOfLine(cursorLine); // Give the cursor some margin so it's not *right* up at the visible edge. lineT -= cursorMargin; lineB += cursorMargin; // Vertical boundaries of the vertical section of text. var visibleT = scrollOffset; var visibleB = scrollOffset + PixelSize.Y; // Make the scroll bar move to a position where the cursor is visible within margin. if (lineT < visibleT) { // Part of the line is ABOVE the visible region, move scrollbar UP. _scrollBar.ValueTarget = lineT; } else if (lineB > visibleB) { // Part of the line is BELOW the visible region, move scrollbar DOWN. _scrollBar.ValueTarget = lineB - PixelHeight; } } private (float start, float end) GetBoundsOfLine(int line) { var font = GetFont(); var lineHeight = font.GetLineHeight(UIScale); return (lineHeight * line, lineHeight * (line + 1)); } private void UpdatePseudoClass() { SetOnlyStylePseudoClass(IsPlaceholderVisible ? StylePseudoClassPlaceholder : null); if (!Editable) AddStylePseudoClass(StylePseudoClassNotEditable); } /// /// Sub-control responsible for doing the actual rendering work. /// /// /// This is a sub-control to use . /// private sealed class RenderBox : Control { // Arrow shapes/data for the debug overlay. private static readonly (Vector2, Vector2)[] ArrowUp = { (new(8, 14), new(8, 2)), (new(4, 7), new(8, 2)), (new(12, 7), new(8, 2)), }; private static readonly (Vector2, Vector2)[] ArrowDown = { (new(8, 14), new(8, 2)), (new(4, 9), new(8, 14)), (new(12, 9), new(8, 14)), }; private static readonly Vector2 ArrowSize = new(16, 16); private readonly TextEdit _master; public RenderBox(TextEdit master) { _master = master; RectClipContent = true; } protected internal override void Draw(DrawingHandleScreen handle) { CursorPos? drawIndexDebug = null; if (_master.DebugOverlay && _master._lastDebugMousePos is { } mouse) { drawIndexDebug = _master.GetIndexAtPos(mouse); } var drawBox = PixelSizeBox; var font = _master.GetFont(); var renderedTextColor = _master.GetFontColor(); if (_master.DebugOverlay && _master._horizontalCursorPos is { } hPos) { handle.DrawLine( new(hPos + drawBox.Left, drawBox.Top), new(hPos + drawBox.Left, drawBox.Bottom), Color.Purple); } var scrollOffset = -_master._scrollBar.Value; var scale = UIScale; var baseLine = new Vector2(0, scrollOffset + font.GetAscent(scale)); var height = font.GetLineHeight(scale); var descent = font.GetDescent(scale); var viewT = -scrollOffset; var startLineIndex = (int)(viewT / height); var startIdx = _master.GetStartOfLine(startLineIndex); var lineBreakIndex = startLineIndex; var count = startIdx; var selectionLower = _master.SelectionLower; var selectionUpper = _master.SelectionUpper; baseLine.Y += startLineIndex * height; int? selectStartPos = null; int? selectEndPos = null; var selecting = false; var imeStartIndex = -1; var imeEndIndex = -1; int? imeStartPos = null; int? imeEndPos = null; var imeing = false; if (_master._imeData.HasValue) { var (start, length) = _master._imeData.Value; imeStartIndex = start.Index; imeEndIndex = imeStartIndex + length; if (imeStartIndex < startIdx && imeEndIndex > startIdx) { imeing = true; imeStartPos = 0; } } if (selectionLower.Index < startIdx && selectionUpper.Index > startIdx) { selecting = true; selectStartPos = 0; } foreach (var rune in Rope.EnumerateRunes(_master.GetDisplayRope(), startIdx)) { CheckDrawCursors(LineBreakBias.Top); if (lineBreakIndex < _master._lineBreaks.Count && _master._lineBreaks[lineBreakIndex] == count) { // Line break // Check to handle PostDrawLine(); baseLine = new Vector2(drawBox.Left, baseLine.Y + height); lineBreakIndex += 1; selectStartPos = selecting ? 0 : null; selectEndPos = null; imeStartPos = imeing ? 0 : null; imeEndPos = null; if (baseLine.Y - height > drawBox.Height) { // Past the bottom of the visible area of the screen: no need to render anything else. break; } } CheckDrawCursors(LineBreakBias.Bottom); baseLine.X += font.DrawChar(handle, rune, baseLine, scale, renderedTextColor); count += rune.Utf16SequenceLength; } // Also draw cursor if it's at the very end. CheckDrawCursors(LineBreakBias.Bottom); CheckDrawCursors(LineBreakBias.Top); PostDrawLine(); // Draw cursor bias if (_master.DebugOverlay) { var arrow = _master.CursorPosition.Bias == LineBreakBias.Bottom ? ArrowDown : ArrowUp; foreach (var (to, from) in arrow) { var offset = new Vector2(0, drawBox.Bottom - ArrowSize.Y); handle.DrawLine(to + offset, from + offset, Color.Green); } } void CheckDrawCursors(LineBreakBias bias) { var pos = new CursorPos(count, bias); if (drawIndexDebug == pos) { handle.DrawRect( new UIBox2( baseLine.X, baseLine.Y - height + descent, baseLine.X + 1, baseLine.Y + descent), Color.Yellow); } if (_master.HasKeyboardFocus() && _master._cursorPosition == pos) { var cursorColor = _master.StylePropertyDefault( StylePropertyCursorColor, Color.White); cursorColor.A *= _master._blink.Opacity; handle.DrawRect( new UIBox2( baseLine.X, baseLine.Y - height + descent, baseLine.X + 1, baseLine.Y + descent), cursorColor); if (UserInterfaceManager.KeyboardFocused == _master && Root?.Window is { } window) { var box = (UIBox2i)new UIBox2( drawBox.Left, baseLine.Y - height + descent, drawBox.Right, baseLine.Y + descent); var cursorOffset = baseLine.X - drawBox.Left; window.TextInputSetRect(box.Translated(GlobalPixelPosition), (int)cursorOffset); } } if (selectionLower == pos) { selecting = true; selectStartPos = (int)baseLine.X; } if (selectionUpper == pos) { selecting = false; selectEndPos = (int)baseLine.X; } if (count == imeStartIndex) { imeing = true; imeStartPos = (int)baseLine.X; } if (count == imeEndIndex) { imeing = false; imeEndPos = (int)baseLine.X; } } void PostDrawLine() { if (selectStartPos != null) { var rect = new UIBox2( selectStartPos.Value, baseLine.Y - height + descent, selectEndPos ?? baseLine.X, baseLine.Y + descent ); var color = _master.StylePropertyDefault( StylePropertySelectionColor, Color.CornflowerBlue.WithAlpha(0.25f)); handle.DrawRect(rect, color); } if (_master._imeData.HasValue && imeStartPos.HasValue) { // Draw IME underline. var y = baseLine.Y + font.GetDescent(scale); var rect = new UIBox2( imeStartPos.Value, y - 1, imeEndPos ?? baseLine.X, y ); handle.DrawRect(rect, renderedTextColor); } } } } protected internal override void KeyboardFocusEntered() { base.KeyboardFocusEntered(); _blink.Reset(); if (Editable) { Root?.Window?.TextInputStart(); } } protected internal override void KeyboardFocusExited() { base.KeyboardFocusExited(); Root?.Window?.TextInputStop(); AbortIme(delete: false); } public sealed class TextEditEventArgs(TextEdit control, Rope.Node textRope) : EventArgs { public TextEdit Control { get; } = control; public Rope.Node TextRope { get; } = textRope; } /// /// Specifies which line the cursor is positioned at when on a word-wrapping break. /// /// /// /// When words get pushed to a new line due to word-wrapping, a line break is tracked. /// For various reasons, people want to be able to place their cursor on both the end of the "top" line, /// as well as the start of the "bottom" line. These are however the same position in the source text, /// going by raw string indices at least. To allow the code to differentiate between these two positions, /// this bias value is tracked in all cursor positions. /// /// /// This is only for word-wrapping line breaks however. For explicit line breaks created with a newline character, /// the cursor bias should always be "top" so that everything works correctly. /// /// public enum LineBreakBias : byte { // @formatter:off Top = 0, Bottom = 1 // @formatter:on } /// /// Stores the necessary data for a position in the cursor of the text. /// /// The index of the cursor in the text contents. /// Which direction to bias the cursor to public record struct CursorPos(int Index, LineBreakBias Bias) : IComparable { public static CursorPos Min(CursorPos a, CursorPos b) { var cmp = a.CompareTo(b); if (cmp < 0) return a; return b; } public static CursorPos Max(CursorPos a, CursorPos b) { var cmp = a.CompareTo(b); if (cmp > 0) return a; return b; } public int CompareTo(CursorPos other) { var indexComparison = Index.CompareTo(other.Index); if (indexComparison != 0) return indexComparison; // If two positions are at the same index, the one with bias top is considered earlier. return ((byte)Bias).CompareTo((byte)other.Bias); } public static bool operator <(CursorPos left, CursorPos right) { return left.CompareTo(right) < 0; } public static bool operator >(CursorPos left, CursorPos right) { return left.CompareTo(right) > 0; } public static bool operator <=(CursorPos left, CursorPos right) { return left.CompareTo(right) <= 0; } public static bool operator >=(CursorPos left, CursorPos right) { return left.CompareTo(right) >= 0; } } [Flags] private enum MoveType { // @formatter:off Left = 1 << 0, Right = 1 << 1, LeftWord = 1 << 2, RightWord = 1 << 3, Up = 1 << 4, Down = 1 << 5, BeginOfLine = 1 << 6, EndOfLine = 1 << 7, ActionMask = (1 << 16) - 1, SelectFlag = 1 << 16, // @formatter:on } } // // Debug commands for TextEdit. // They work on the active focused control, so you *need* to bind these to a key and press the key. // // bind F12 Command textedit_ropeviz internal sealed partial class TextEditRopeVizCommand : IConsoleCommand { [Dependency] private IUserInterfaceManager _ui = default!; public string Command => "textedit_ropeviz"; public string Description => ""; public string Help => ""; public void Execute(IConsoleShell shell, string argStr, string[] args) { if (_ui.KeyboardFocused is TextEdit te) { new TextEditRopeViz(te).Show(); } } } // bind F11 Command textedit_rebalance internal sealed partial class TextEditRebalanceCommand : IConsoleCommand { [Dependency] private IUserInterfaceManager _ui = default!; public string Command => "textedit_rebalance"; public string Description => ""; public string Help => ""; public void Execute(IConsoleShell shell, string argStr, string[] args) { if (_ui.KeyboardFocused is TextEdit te) { te.TextRope = Rope.Rebalance(te.TextRope); } } } // bind F10 Command textedit_debugoverlay internal sealed partial class TextEditDebugOverlayCommand : IConsoleCommand { [Dependency] private IUserInterfaceManager _ui = default!; public string Command => "textedit_debugoverlay"; public string Description => ""; public string Help => ""; public void Execute(IConsoleShell shell, string argStr, string[] args) { if (_ui.KeyboardFocused is TextEdit te) { te.DebugOverlay ^= true; } } } // bind F9 Command textedit_queuelinebreak internal sealed partial class TextEditQueueLineBreakCommand : IConsoleCommand { [Dependency] private IUserInterfaceManager _ui = default!; public string Command => "textedit_queuelinebreak"; public string Description => ""; public string Help => ""; public void Execute(IConsoleShell shell, string argStr, string[] args) { if (_ui.KeyboardFocused is TextEdit te) { te.QueueLineBreakUpdate(); } } }