From ae57ab52f4c1ded1f9aea8f76fc14ec89109a856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Thu, 10 Apr 2025 03:24:27 +0200 Subject: [PATCH 1/5] Create prototype for multiline textbox --- .../Visual/UserInterface/TestSceneTextArea.cs | 35 ++ .../Graphics/Containers/FillFlowContainer.cs | 10 +- osu.Framework/Graphics/Sprites/SpriteText.cs | 2 +- .../Graphics/Sprites/SpriteText_DrawNode.cs | 4 +- .../Graphics/UserInterface/TextArea.cs | 549 ++++++++++++++++++ .../Graphics/UserInterface/TextAreaCaret.cs | 41 ++ .../UserInterface/TextAreaTextLayout.cs | 287 +++++++++ .../Graphics/UserInterface/TextPosition.cs | 7 + osu.Framework/Text/TextAreaSelection.cs | 36 ++ osu.Framework/Text/TextSelection.cs | 283 +++++++++ 10 files changed, 1250 insertions(+), 4 deletions(-) create mode 100644 osu.Framework.Tests/Visual/UserInterface/TestSceneTextArea.cs create mode 100644 osu.Framework/Graphics/UserInterface/TextArea.cs create mode 100644 osu.Framework/Graphics/UserInterface/TextAreaCaret.cs create mode 100644 osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs create mode 100644 osu.Framework/Graphics/UserInterface/TextPosition.cs create mode 100644 osu.Framework/Text/TextAreaSelection.cs create mode 100644 osu.Framework/Text/TextSelection.cs diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextArea.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextArea.cs new file mode 100644 index 0000000000..b2428d96cf --- /dev/null +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextArea.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; + +namespace osu.Framework.Tests.Visual.UserInterface +{ + public partial class TestSceneTextArea : ManualInputManagerTestScene + { + [BackgroundDependencyLoader] + private void load() + { + Add(new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = new TextArea + { + RelativeSizeAxes = Axes.None, + Width = 300, + Height = 200, + Text = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " + + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " + + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" + } + }); + } + } +} diff --git a/osu.Framework/Graphics/Containers/FillFlowContainer.cs b/osu.Framework/Graphics/Containers/FillFlowContainer.cs index 68adcee5f5..88587ba1b8 100644 --- a/osu.Framework/Graphics/Containers/FillFlowContainer.cs +++ b/osu.Framework/Graphics/Containers/FillFlowContainer.cs @@ -183,7 +183,7 @@ static Axes toAxes(FillDirection direction) float rowWidth = rowBeginOffset + current.X + (1 - spacingFactor(c).X) * size.X; //We've exceeded our allowed width, move to a new row - if (direction != FillDirection.Horizontal && (Precision.DefinitelyBigger(rowWidth, max.X) || direction == FillDirection.Vertical)) + if (direction != FillDirection.Horizontal && (Precision.DefinitelyBigger(rowWidth, max.X) || direction == FillDirection.Vertical || ForceNewRow(c))) { current.X = 0; current.Y += rowHeight; @@ -286,6 +286,8 @@ static Axes toAxes(FillDirection direction) yield return layoutPosition; } + + OnLayoutComputed(children, layoutPositions, rowIndices); } finally { @@ -293,6 +295,12 @@ static Axes toAxes(FillDirection direction) ArrayPool.Shared.Return(rowIndices); } } + + protected virtual void OnLayoutComputed(Drawable[] children, Vector2[] layoutPositions, int[] rowIndices) + { + } + + protected virtual bool ForceNewRow(Drawable drawable) => false; } /// diff --git a/osu.Framework/Graphics/Sprites/SpriteText.cs b/osu.Framework/Graphics/Sprites/SpriteText.cs index dce188d6b6..b9de553cdb 100644 --- a/osu.Framework/Graphics/Sprites/SpriteText.cs +++ b/osu.Framework/Graphics/Sprites/SpriteText.cs @@ -444,7 +444,7 @@ public MarginPadding Padding /// /// The characters in local space. /// - private List characters + internal List Characters { get { diff --git a/osu.Framework/Graphics/Sprites/SpriteText_DrawNode.cs b/osu.Framework/Graphics/Sprites/SpriteText_DrawNode.cs index 5657a4c700..5f1c3f2840 100644 --- a/osu.Framework/Graphics/Sprites/SpriteText_DrawNode.cs +++ b/osu.Framework/Graphics/Sprites/SpriteText_DrawNode.cs @@ -86,7 +86,7 @@ protected override void Draw(IRenderer renderer) /// private void updateScreenSpaceCharacters() { - int partCount = Source.characters.Count; + int partCount = Source.Characters.Count; if (parts == null) parts = new List(partCount); @@ -98,7 +98,7 @@ private void updateScreenSpaceCharacters() Vector2 inflationAmount = DrawInfo.MatrixInverse.ExtractScale().Xy; - foreach (var character in Source.characters) + foreach (var character in Source.Characters) { parts.Add(new ScreenSpaceCharacterPart { diff --git a/osu.Framework/Graphics/UserInterface/TextArea.cs b/osu.Framework/Graphics/UserInterface/TextArea.cs new file mode 100644 index 0000000000..8c0f8276c0 --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/TextArea.cs @@ -0,0 +1,549 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Text; +using osu.Framework.Utils; +using osuTK; +using osuTK.Input; + +namespace osu.Framework.Graphics.UserInterface +{ + public partial class TextArea : CompositeDrawable, IKeyBindingHandler + { + public FontUsage Font { get; init; } = FontUsage.Default; + + public TextInputProperties InputProperties { get; init; } + + private string text = string.Empty; + + public string Text + { + get => text; + set + { + if (value == text) + return; + + text = value; + textBacking.Invalidate(); + } + } + + protected readonly Container TextContainer; + + private readonly TextAreaTextLayout textLayout; + private readonly TextAreaCaret caret; + private readonly TextSelection selection; + + private readonly ScrollContainer scroll; + + // Keeps track of the caret's x position of the most recent horizontal navigation + private readonly Cached caretXPosition = new Cached(); + + private readonly Cached textBacking = new Cached(); + + public TextArea() + { + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.2f, + }, + scroll = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = TextContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(8), + Children = new Drawable[] + { + textLayout = new TextAreaTextLayout + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + caret = new TextAreaCaret + { + BypassAutoSizeAxes = Axes.Both, + } + } + } + } + }; + + selection = new TextAreaSelection(() => text, textLayout); + + selection.SelectionChanged += direction => + { + caretBacking.Invalidate(); + if (direction != Direction.Vertical) + caretXPosition.Invalidate(); + + Logger.Log($"Selection changed [{selection.SelectionStart}, {selection.SelectionEnd}]"); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + caret.Hide(); + } + + protected override void Update() + { + base.Update(); + + if (!textBacking.IsValid) + { + rebuildText(); + textBacking.Validate(); + } + } + + private readonly Cached caretBacking = new Cached(); + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!caretBacking.IsValid) + { + updateCaret(); + + caretBacking.Validate(); + } + } + + private void updateCaret() + { + if (selection.HasSelection) + { + var selectionStart = textLayout.IndexToTextPosition(selection.SelectionLeft); + var selectionEnd = textLayout.IndexToTextPosition(selection.SelectionRight); + + var selectionRects = new List(); + + for (int line = selectionStart.Row; line <= selectionEnd.Row; line++) + { + var selectionRect = textLayout.Lines[line].Bounds; + + if (line == selectionStart.Row) + { + var position = textLayout.GetCharacterPosition(selection.SelectionLeft); + + selectionRect.Width -= position.X - selectionRect.X; + selectionRect.X = position.X; + } + + if (line == selectionEnd.Row) + { + var position = textLayout.GetCharacterPosition(selection.SelectionRight); + + selectionRect.Width = position.X - selectionRect.Left; + } + + if (selectionStart.Row != selectionEnd.Row && Precision.AlmostEquals(selectionRect.Width, 0f)) + continue; + + selectionRects.Add(selectionRect); + } + + var caretPos = textLayout.GetCharacterPosition(selection.SelectionEnd); + + var drawable = textLayout.GetDrawableAt(selection.SelectionEnd); + + if (drawable != null) + scroll.ScrollIntoView(drawable); + + if (!caretXPosition.IsValid) + { + caretXPosition.Value = caretPos.X; + } + + caret.DisplayRange(selectionRects); + } + else + { + var position = textLayout.GetCharacterPosition(selection.SelectionStart); + + caret.DisplayAt(position, Font.Size); + + if (!caretXPosition.IsValid) + caretXPosition.Value = position.X; + + var drawable = textLayout.GetDrawableAt(selection.SelectionEnd); + + if (drawable != null) + scroll.ScrollIntoView(drawable); + } + } + + private void rebuildText() + { + textLayout.Clear(); + + var words = splitWords(text); + + Logger.Log(words.ToString()); + + foreach (var word in words) + { + textLayout.Add(new TextAreaTextLayout.TextAreaWord + { + Text = word.Text, + IsNewLine = word.IsNewLine, + }); + } + } + + private readonly record struct Word(string Text, bool IsNewLine); + + private Word[] splitWords(string text) + { + var words = new List(); + var builder = new StringBuilder(); + + bool lastWordWasNewline = false; + + for (int i = 0; i < text.Length; i++) + { + if (i == 0 + || char.IsSeparator(text[i - 1]) + || char.IsControl(text[i - 1]) + || char.GetUnicodeCategory(text[i - 1]) == UnicodeCategory.DashPunctuation + || text[i - 1] == '/' + || text[i - 1] == '\\' + || (isCjkCharacter(text[i - 1]) && !char.IsPunctuation(text[i]))) + { + words.Add(new Word(builder.ToString(), lastWordWasNewline)); + builder.Clear(); + + lastWordWasNewline = i > 0 && text[i - 1] == '\n'; + } + + bool isNewLine = text[i] == '\n'; + + builder.Append(isNewLine ? ' ' : text[i]); + } + + if (builder.Length > 0) + words.Add(new Word(builder.ToString(), false)); + + return words.ToArray(); + + bool isCjkCharacter(char c) => c >= '\x2E80' && c <= '\x9FFF'; + } + + protected void MoveCursorVertically(int amount) + { + float? xPosition = + caretXPosition.IsValid + ? caretXPosition.Value + : textLayout.GetCharacterPosition(selection.SelectionStart).X; + + var position = textLayout.IndexToTextPosition(selection.SelectionStart); + + int lineIndex = int.Clamp(position.Row + amount, 0, textLayout.Lines.Length - 1); + + int index = textLayout.TextPositionToIndex(new TextPosition(lineIndex, textLayout.GetClosestCharacterForLine(textLayout.Lines[lineIndex], xPosition.Value))); + + selection.MoveCursorTo(index, Direction.Vertical); + caretBacking.Invalidate(); + } + + private void insertText(string text) + { + deleteSelection(); + this.text = this.text.Insert(selection.SelectionStart, text); + textBacking.Invalidate(); + selection.MoveForwardChar(); + } + + private void deleteSelection() + { + if (!selection.HasSelection) + return; + + text = text.Remove(selection.SelectionLeft, selection.SelectionLength); + selection.MoveCursorTo(selection.SelectionLeft); + + textBacking.Invalidate(); + } + + protected void ExpandSelectionVertically(int amount) + { + float? xPosition = + caretXPosition.IsValid + ? caretXPosition.Value + : textLayout.GetCharacterPosition(selection.SelectionEnd).X; + + var position = textLayout.IndexToTextPosition(selection.SelectionEnd); + + if (position.Row + amount < 0) + { + selection.SetSelection(selection.SelectionStart, 0, Direction.Vertical); + return; + } + + if (position.Row + amount >= textLayout.Lines.Length) + { + selection.SetSelection(selection.SelectionStart, text.Length, Direction.Vertical); + return; + } + + int lineIndex = position.Row + amount; + + int index = textLayout.TextPositionToIndex(new TextPosition(lineIndex, textLayout.GetClosestCharacterForLine(textLayout.Lines[lineIndex], xPosition.Value))); + + selection.SetSelection(selection.SelectionStart, index, Direction.Vertical); + caretBacking.Invalidate(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Up: + if (e.ShiftPressed) + ExpandSelectionVertically(-1); + else + MoveCursorVertically(-1); + return true; + + case Key.Down: + if (e.ShiftPressed) + ExpandSelectionVertically(1); + else + MoveCursorVertically(1); + return true; + + case Key.Enter: + insertText("\n"); + return true; + } + + return base.OnKeyDown(e); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case PlatformAction.SelectAll: + selection.SelectAll(); + return true; + + case PlatformAction.MoveForwardChar: + selection.MoveForwardChar(); + return true; + + case PlatformAction.MoveBackwardChar: + selection.MoveBackwardChar(); + return true; + + case PlatformAction.MoveBackwardWord: + selection.MoveBackwardWord(); + return true; + + case PlatformAction.MoveForwardWord: + selection.MoveForwardWord(); + return true; + + case PlatformAction.MoveForwardLine: + selection.MoveForwardLine(); + return true; + + case PlatformAction.MoveBackwardLine: + selection.MoveBackwardLine(); + return true; + + case PlatformAction.SelectBackwardChar: + selection.SelectBackwardChar(); + return true; + + case PlatformAction.SelectForwardChar: + selection.SelectForwardChar(); + return true; + + case PlatformAction.SelectBackwardWord: + selection.SelectBackwardWord(); + return true; + + case PlatformAction.SelectForwardWord: + selection.SelectForwardWord(); + return true; + + case PlatformAction.SelectBackwardLine: + selection.SelectBackwardLine(); + return true; + + case PlatformAction.SelectForwardLine: + selection.SelectForwardLine(); + return true; + + case PlatformAction.DeleteBackwardChar: + if (!selection.HasSelection) + selection.SelectBackwardChar(); + + deleteSelection(); + return true; + + case PlatformAction.DeleteForwardChar: + if (!selection.HasSelection) + selection.SelectForwardChar(); + + deleteSelection(); + return true; + + case PlatformAction.DeleteBackwardWord: + if (!selection.HasSelection) + selection.SelectBackwardWord(); + + deleteSelection(); + return true; + + case PlatformAction.DeleteForwardWord: + if (!selection.HasSelection) + selection.SelectForwardWord(); + + deleteSelection(); + return true; + + case PlatformAction.DeleteBackwardLine: + if (!selection.HasSelection) + selection.SelectBackwardLine(); + + deleteSelection(); + return true; + + case PlatformAction.DeleteForwardLine: + if (!selection.HasSelection) + selection.SelectForwardLine(); + + deleteSelection(); + selection.MoveCursorTo(selection.SelectionLeft); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected override bool OnClick(ClickEvent e) + { + var position = textLayout.GetClosestTextPosition(textLayout.ToLocalSpace(e.ScreenSpaceMousePosition)); + + selection.MoveCursorTo(textLayout.TextPositionToIndex(position)); + + return true; + } + + protected override bool OnDoubleClick(DoubleClickEvent e) + { + var position = textLayout.GetClosestTextPosition(textLayout.ToLocalSpace(e.ScreenSpaceMousePosition)); + + selection.SelectWord(textLayout.TextPositionToIndex(position)); + + return true; + } + + // TODO: see if there's a better way of preventing the scroll container from accepting mouse input. + protected override bool OnMouseDown(MouseDownEvent e) => e.Button == MouseButton.Left; + + internal override bool BuildPositionalInputQueue(Vector2 screenSpacePos, List queue) + { + if (!base.BuildPositionalInputQueue(screenSpacePos, queue)) + return false; + + queue.Remove(this); + queue.Add(this); + + return true; + } + + internal override bool BuildNonPositionalInputQueue(List queue, bool allowBlocking = true) + { + if (!base.BuildNonPositionalInputQueue(queue, allowBlocking)) + return false; + + queue.Remove(this); + queue.Add(this); + + return true; + } + + [Resolved] + private TextInputSource textInput { get; set; } = null!; + + /// + /// Whether has been activated and bound to. + /// + private bool textInputBound; + + private void bindInput() + { + if (textInputBound) + { + textInput.EnsureActivated(InputProperties); + return; + } + + textInput.Activate(InputProperties, ScreenSpaceDrawQuad.AABBFloat); + textInput.OnTextInput += insertText; + + textInputBound = true; + } + + private void unbindInput() + { + if (!textInputBound) + return; + + textInput.Deactivate(); + textInput.OnTextInput -= insertText; + + textInputBound = false; + } + + public override bool AcceptsFocus => true; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + bindInput(); + caret.Show(); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + unbindInput(); + caret.Hide(); + } + } +} diff --git a/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs b/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs new file mode 100644 index 0000000000..38a261c393 --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osuTK; + +namespace osu.Framework.Graphics.UserInterface +{ + public partial class TextAreaCaret : CompositeDrawable + { + public void DisplayAt(Vector2 position, float lineHeight) + { + InternalChild = new Box + { + Position = position, + Origin = Anchor.TopCentre, + Size = new Vector2(1.5f, lineHeight) + }; + } + + public void DisplayRange(IEnumerable selectionRects) + { + ClearInternal(); + + foreach (var rect in selectionRects) + { + AddInternal(new Box + { + Position = rect.TopLeft, + Size = rect.Size, + Alpha = 0.5f, + }); + } + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } +} diff --git a/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs b/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs new file mode 100644 index 0000000000..6fab6fd17f --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs @@ -0,0 +1,287 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; +using osuTK; + +namespace osu.Framework.Graphics.UserInterface +{ + public partial class TextAreaTextLayout : FillFlowContainer + { + public partial class TextAreaWord : SpriteText + { + public bool IsNewLine; + + public override bool IsPresent => true; + } + + internal LineInfo[] Lines { get; private set; } = Array.Empty(); + + public override IEnumerable FlowingChildren => AliveInternalChildren.OrderBy(d => d.ChildID); + + internal bool TryGetLineInfo(int index, [NotNullWhen(true)] out LineInfo? line) + { + if (index < 0 || index >= Lines.Length) + { + line = null; + return false; + } + + line = Lines[index]; + return true; + } + + protected override bool ForceNewRow(Drawable drawable) => ((TextAreaWord)drawable).IsNewLine; + + protected override void OnLayoutComputed(Drawable[] children, Vector2[] layoutPositions, int[] rowIndices) + { + base.OnLayoutComputed(children, layoutPositions, rowIndices); + + if (children.Length == 0) + { + Lines = new[] + { + new LineInfo(default, 0, 0, 0, 0) + }; + return; + } + + // rowIndices may be larger than children.Length + Lines = new LineInfo[rowIndices.Take(children.Length).Max() + 1]; + + Logger.Log($"row indices: {string.Join(", ", rowIndices)}, children: {children.Length}"); + + RectangleF currentLineBounds = default; + + int lastRowIndex = 0; + int positionInString = 0; + int lineStartPositionInString = 0; + + int firstDrawableInLine = 0; + + for (int i = 0; i < children.Length; i++) + { + var child = (SpriteText)children[i]; + var position = layoutPositions[i]; + var childSize = child.BoundingBox.Size; + int rowIndex = rowIndices[i]; + + var childBounds = new RectangleF(position, childSize); + + if (rowIndex != lastRowIndex) + { + Lines[lastRowIndex] = new LineInfo(currentLineBounds, lineStartPositionInString, positionInString, firstDrawableInLine, i - firstDrawableInLine); + + currentLineBounds = childBounds; + lineStartPositionInString = positionInString; + firstDrawableInLine = i; + } + else + { + currentLineBounds = RectangleF.Union(currentLineBounds, childBounds); + } + + positionInString += child.Characters.Count; + lastRowIndex = rowIndex; + + if (i == children.Length - 1) + { + // we can navigate 1 character beyond the string's length so we add an extra character here + Lines[rowIndex] = new LineInfo(currentLineBounds, lineStartPositionInString, positionInString + 1, firstDrawableInLine, i - firstDrawableInLine + 1); + } + } + } + + public int TextPositionToIndex(TextPosition position) + { + var line = Lines[position.Row]; + + return line.StartOffset + position.Column; + } + + public TextPosition IndexToTextPosition(int index) + { + if (Lines.Length == 0) + return new TextPosition(); + + for (int i = 0; i < Lines.Length; i++) + { + var line = Lines[i]; + + if (index < line.EndOffset) + { + return new TextPosition(i, index - line.StartOffset); + } + } + + return new TextPosition(Lines.Length - 1, Lines[^1].EndOffset); + } + + public SpriteText? GetDrawableAt(int index) + { + var position = IndexToTextPosition(index); + + var line = Lines[position.Row]; + + var flowingChildren = FlowingChildren.ToArray(); + + if (flowingChildren.Length == 0) + return null; + + int charactersSoFar = 0; + + if (position.Row == Lines.Length - 1 && position.Column >= line.CharacterCount - 1) + { + return (SpriteText)flowingChildren[line.LastDrawableIndex]; + } + + if (position.Column >= line.CharacterCount) + { + return (SpriteText)flowingChildren[line.LastDrawableIndex]; + } + + for (int i = line.FirstDrawableIndex; i <= line.LastDrawableIndex; i++) + { + var spriteText = (SpriteText)flowingChildren[i]; + + if (spriteText.Characters.Count == 0) + continue; + + if (charactersSoFar + spriteText.Characters.Count <= position.Column) + { + charactersSoFar += spriteText.Characters.Count; + continue; + } + + return spriteText; + } + + return (SpriteText)flowingChildren[^1]; + } + + public Vector2 GetCharacterPosition(int index) + { + var position = IndexToTextPosition(index); + + var line = Lines[position.Row]; + + var flowingChildren = FlowingChildren.ToArray(); + + if (flowingChildren.Length == 0) + return new Vector2(); + + int charactersSoFar = 0; + + if (position.Row == Lines.Length - 1 && position.Column >= line.CharacterCount - 1) + { + // Special case for the last line, since it's true length is shorter by a character + var spriteText = (SpriteText)flowingChildren[line.LastDrawableIndex]; + + return spriteText.BoundingBox.TopRight; + } + + if (position.Column >= line.CharacterCount) + { + var spriteText = (SpriteText)flowingChildren[line.LastDrawableIndex]; + + return spriteText.BoundingBox.TopRight; + } + + for (int i = line.FirstDrawableIndex; i <= line.LastDrawableIndex; i++) + { + var spriteText = (SpriteText)flowingChildren[i]; + + if (spriteText.Characters.Count == 0) + continue; + + if (charactersSoFar + spriteText.Characters.Count <= position.Column) + { + charactersSoFar += spriteText.Characters.Count; + continue; + } + + var glyph = spriteText.Characters[position.Column - charactersSoFar]; + + float glyphPosition = glyph.DrawRectangle.Left - glyph.XOffset; + + return spriteText.BoundingBox.TopLeft + new Vector2(glyphPosition, 0); + } + + return flowingChildren[^1].BoundingBox.TopRight; + } + + internal TextPosition GetClosestTextPosition(Vector2 position) + { + for (int lineIndex = 0; lineIndex < Lines.Length; lineIndex++) + { + var line = Lines[lineIndex]; + + if (line.Bounds.Bottom < position.Y) + continue; + + return new TextPosition( + lineIndex, + GetClosestCharacterForLine(line, position.X) + ); + } + + // if we land here it means that the position is below the last line + return new TextPosition(Lines.Length - 1, GetClosestCharacterForLine(Lines[^1], position.X)); + } + + internal int GetClosestCharacterForLine(LineInfo line, float xOffset) + { + var flowingChildren = FlowingChildren.ToArray(); + + int charactersSoFar = 0; + + for (int i = line.FirstDrawableIndex; i <= line.LastDrawableIndex; i++) + { + var spriteText = (SpriteText)flowingChildren[i]; + + var textBounds = spriteText.BoundingBox; + + if (xOffset < textBounds.Left) + { + // Since we're going left-to-right this always means that the position is before the line's first character + return 0; + } + + if (xOffset > textBounds.Right) + { + // we have not reached the character yet + charactersSoFar += spriteText.Characters.Count; + continue; + } + + for (int j = 0; j < spriteText.Characters.Count; j++) + { + var glyph = spriteText.Characters[j]; + + float glyphPosition = glyph.DrawRectangle.Centre.X - glyph.XOffset; + + if (xOffset < textBounds.Left + glyphPosition) + return charactersSoFar + j; + } + + return charactersSoFar + spriteText.Characters.Count; + } + + return line.CharacterCount - 1; + } + + internal record LineInfo(RectangleF Bounds, int StartOffset, int EndOffset, int FirstDrawableIndex, int DrawableCount) + { + public int CharacterCount => EndOffset - StartOffset; + + public int LastDrawableIndex => FirstDrawableIndex + DrawableCount - 1; + } + } +} diff --git a/osu.Framework/Graphics/UserInterface/TextPosition.cs b/osu.Framework/Graphics/UserInterface/TextPosition.cs new file mode 100644 index 0000000000..e68524c1d0 --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/TextPosition.cs @@ -0,0 +1,7 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Framework.Graphics.UserInterface +{ + public readonly record struct TextPosition(int Row = 0, int Column = 0); +} diff --git a/osu.Framework/Text/TextAreaSelection.cs b/osu.Framework/Text/TextAreaSelection.cs new file mode 100644 index 0000000000..5f5fac1232 --- /dev/null +++ b/osu.Framework/Text/TextAreaSelection.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.UserInterface; + +namespace osu.Framework.Text +{ + public class TextAreaSelection : TextSelection + { + private readonly TextAreaTextLayout layout; + + public TextAreaSelection(Func getText, TextAreaTextLayout layout) + : base(getText) + { + this.layout = layout; + } + + public override int GetLineStart(int position) + { + var textPosition = layout.IndexToTextPosition(position); + + return layout.TextPositionToIndex(textPosition with { Column = 0 }); + } + + public override int GetLineEnd(int position) + { + var textPosition = layout.IndexToTextPosition(position); + + if (layout.TryGetLineInfo(textPosition.Row, out var line)) + return layout.TextPositionToIndex(textPosition with { Column = line.CharacterCount - 1 }); + + return position; + } + } +} diff --git a/osu.Framework/Text/TextSelection.cs b/osu.Framework/Text/TextSelection.cs new file mode 100644 index 0000000000..2bf15131de --- /dev/null +++ b/osu.Framework/Text/TextSelection.cs @@ -0,0 +1,283 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Graphics; + +namespace osu.Framework.Text +{ + public class TextSelection + { + private readonly Func getText; + + public int SelectionStart { get; protected set; } + public int SelectionEnd { get; protected set; } + + public virtual bool AllowWordNavigation { get; set; } = true; + + public int SelectionLeft => int.Min(SelectionStart, SelectionEnd); + public int SelectionRight => int.Max(SelectionStart, SelectionEnd); + + public int SelectionLength => SelectionRight - SelectionLeft; + + public delegate void SelectionChangedHandler(Direction direction); + + public event SelectionChangedHandler? SelectionChanged; + + public TextSelection(Func getText) + { + this.getText = getText; + } + + public bool HasSelection => SelectionLength > 0; + + private string text => getText(); + + public void SetSelection(int start, int end, Direction direction = Direction.Horizontal) + { + SelectionStart = int.Clamp(start, 0, text.Length + 1); + SelectionEnd = int.Clamp(end, 0, text.Length + 1); + SelectionChanged?.Invoke(direction); + } + + public void MoveBackwardChar() + { + if (HasSelection) + MoveCursorBy(SelectionLeft - SelectionEnd); + else + MoveCursorBy(-1); + } + + public void MoveForwardChar() + { + if (HasSelection) + MoveCursorBy(SelectionRight - SelectionEnd); + else + MoveCursorBy(1); + } + + public void MoveBackwardWord() + { + if (HasSelection) + { + MoveCursorBy(SelectionLeft - SelectionEnd); + } + else + { + MoveCursorBy(GetBackwardWordAmount()); + } + } + + public void MoveForwardWord() + { + if (HasSelection) + { + MoveCursorBy(SelectionRight - SelectionEnd); + } + else + { + MoveCursorBy(GetForwardWordAmount()); + } + } + + public void MoveBackwardLine() + { + MoveCursorTo(GetLineStart(SelectionLeft)); + } + + public void MoveForwardLine() + { + MoveCursorTo(GetLineEnd(SelectionRight)); + } + + public void SelectBackwardChar() + { + ExpandSelectionBy(-1); + } + + public void SelectForwardChar() + { + ExpandSelectionBy(1); + } + + public void SelectBackwardWord() + { + ExpandSelectionBy(GetBackwardWordAmount()); + } + + public void SelectForwardWord() + { + ExpandSelectionBy(GetForwardWordAmount()); + } + + public void SelectBackwardLine() + { + ExpandSelectionBy(GetBackwardLineAmount()); + } + + public void SelectForwardLine() + { + ExpandSelectionBy(GetForwardLineAmount()); + } + + protected void ExpandSelectionBy(int amount) + { + moveSelection(amount, true); + } + + public void MoveCursorTo(int position, Direction direction = Direction.Horizontal) + { + SelectionStart = SelectionEnd = position; + SelectionChanged?.Invoke(direction); + } + + public void MoveCursorBy(int amount) + { + SelectionStart = SelectionEnd; + moveSelection(amount, false); + SelectionChanged?.Invoke(Direction.Horizontal); + } + + private void moveSelection(int offset, bool expand) + { + int oldStart = SelectionStart; + int oldEnd = SelectionEnd; + + if (expand) + SelectionEnd = Math.Clamp(SelectionEnd + offset, 0, text.Length); + else + { + if (HasSelection && Math.Abs(offset) <= 1) + { + //we don't want to move the location when "removing" an existing selection, just set the new location. + if (offset > 0) + SelectionEnd = SelectionStart = SelectionRight; + else + SelectionEnd = SelectionStart = SelectionLeft; + } + else + SelectionEnd = SelectionStart = Math.Clamp((offset > 0 ? SelectionRight : SelectionLeft) + offset, 0, text.Length); + } + + if (oldStart != SelectionStart || oldEnd != SelectionEnd) + SelectionChanged?.Invoke(Direction.Horizontal); + } + + public int GetBackwardWordAmount() + { + if (!AllowWordNavigation) + return -1; + + return findNextWord(text, SelectionEnd, -1) - SelectionEnd; + } + + public int GetForwardWordAmount() + { + if (!AllowWordNavigation) + return 1; + + return findNextWord(text, SelectionEnd, 1) - SelectionEnd; + } + + private static int findNextWord(string text, int position, int direction) + { + Debug.Assert(direction == -1 || direction == 1); + + // When going backwards, the initial position will always be the index of the first character in the next word, + // but it should be the index of the character in the last word. + if (direction == -1) + position -= 1; + + WordTraversalStep currentStep = WordTraversalStep.Whitespace; + + while (true) + { + if (position < 0) + return 0; + + if (position >= text.Length) + return text.Length; + + char character = text[position]; + + switch (currentStep) + { + case WordTraversalStep.Whitespace: + if (char.IsWhiteSpace(character)) + position += direction; + else if (char.IsLetterOrDigit(character)) + currentStep = WordTraversalStep.LetterOrDigit; + else + currentStep = WordTraversalStep.Symbol; + + continue; + + case WordTraversalStep.Symbol: + if (char.IsLetterOrDigit(character)) + currentStep = WordTraversalStep.LetterOrDigit; + else if (char.IsWhiteSpace(character)) + break; + + position += direction; + continue; + + case WordTraversalStep.LetterOrDigit: + if (char.IsLetterOrDigit(character)) + { + position += direction; + continue; + } + + break; + } + + break; + } + + // When going backwards, the final position will always be the the index of the last character of the previous word, + // but it should be the index of the first character in the next word. + if (direction == -1) + position += 1; + + return position; + } + + public virtual int GetLineStart(int position) => 0; + + public virtual int GetLineEnd(int position) => text.Length; + + public int GetBackwardLineAmount() + { + return GetLineStart(SelectionEnd) - SelectionEnd; + } + + public int GetForwardLineAmount() + { + return GetLineEnd(SelectionEnd) - SelectionEnd; + } + + public void SelectWord(int position) + { + SelectionEnd = position; + + SelectionStart = position + GetBackwardWordAmount(); + SelectionEnd = position + GetForwardWordAmount(); + SelectionChanged?.Invoke(Direction.Horizontal); + } + + public void SelectAll() + { + SelectionStart = 0; + SelectionEnd = text.Length; + SelectionChanged?.Invoke(Direction.Horizontal); + } + + private enum WordTraversalStep + { + Whitespace, + LetterOrDigit, + Symbol, + } + } +} From ce96946c43bb02a43a20e432bfcaffa4e4e2a3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Thu, 10 Apr 2025 03:26:55 +0200 Subject: [PATCH 2/5] Remove logging statement --- osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs b/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs index 6fab6fd17f..e03d472c45 100644 --- a/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs +++ b/osu.Framework/Graphics/UserInterface/TextAreaTextLayout.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Sprites; -using osu.Framework.Logging; using osuTK; namespace osu.Framework.Graphics.UserInterface @@ -56,8 +55,6 @@ protected override void OnLayoutComputed(Drawable[] children, Vector2[] layoutPo // rowIndices may be larger than children.Length Lines = new LineInfo[rowIndices.Take(children.Length).Max() + 1]; - Logger.Log($"row indices: {string.Join(", ", rowIndices)}, children: {children.Length}"); - RectangleF currentLineBounds = default; int lastRowIndex = 0; From f20ad0072b1e5ecfbaf320dded5974afd6a3906e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Thu, 10 Apr 2025 03:35:20 +0200 Subject: [PATCH 3/5] Add basic handling for drag events --- .../Graphics/UserInterface/TextArea.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/osu.Framework/Graphics/UserInterface/TextArea.cs b/osu.Framework/Graphics/UserInterface/TextArea.cs index 8c0f8276c0..4f74630b6b 100644 --- a/osu.Framework/Graphics/UserInterface/TextArea.cs +++ b/osu.Framework/Graphics/UserInterface/TextArea.cs @@ -473,6 +473,31 @@ protected override bool OnDoubleClick(DoubleClickEvent e) // TODO: see if there's a better way of preventing the scroll container from accepting mouse input. protected override bool OnMouseDown(MouseDownEvent e) => e.Button == MouseButton.Left; + protected override bool OnDragStart(DragStartEvent e) + { + var position = textLayout.GetClosestTextPosition(textLayout.ToLocalSpace(e.ScreenSpaceMouseDownPosition)); + + selection.MoveCursorTo(textLayout.TextPositionToIndex(position)); + + handleDrag(e); + + return true; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + handleDrag(e); + } + + private void handleDrag(MouseButtonEvent e) + { + var position = textLayout.GetClosestTextPosition(textLayout.ToLocalSpace(e.ScreenSpaceMousePosition)); + + selection.SetSelection(selection.SelectionStart, textLayout.TextPositionToIndex(position)); + } + internal override bool BuildPositionalInputQueue(Vector2 screenSpacePos, List queue) { if (!base.BuildPositionalInputQueue(screenSpacePos, queue)) From b6c24f08db222370dfd13933911631d9cf400d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Thu, 10 Apr 2025 03:35:32 +0200 Subject: [PATCH 4/5] Prevent flickering when caret updates --- osu.Framework/Graphics/UserInterface/TextAreaCaret.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs b/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs index 38a261c393..1b316235de 100644 --- a/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs +++ b/osu.Framework/Graphics/UserInterface/TextAreaCaret.cs @@ -13,17 +13,21 @@ public partial class TextAreaCaret : CompositeDrawable { public void DisplayAt(Vector2 position, float lineHeight) { - InternalChild = new Box + foreach (var c in InternalChildren) + c.Expire(); + + AddInternal(new Box { Position = position, Origin = Anchor.TopCentre, Size = new Vector2(1.5f, lineHeight) - }; + }); } public void DisplayRange(IEnumerable selectionRects) { - ClearInternal(); + foreach (var c in InternalChildren) + c.Expire(); foreach (var rect in selectionRects) { From f838e4a1e3daa14f01327ca91295b68f82ee6746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Thu, 10 Apr 2025 03:47:27 +0200 Subject: [PATCH 5/5] Ignore keyobard input when not in focus --- osu.Framework/Graphics/UserInterface/TextArea.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Framework/Graphics/UserInterface/TextArea.cs b/osu.Framework/Graphics/UserInterface/TextArea.cs index 4f74630b6b..96ccb7b2ed 100644 --- a/osu.Framework/Graphics/UserInterface/TextArea.cs +++ b/osu.Framework/Graphics/UserInterface/TextArea.cs @@ -321,6 +321,9 @@ protected void ExpandSelectionVertically(int amount) protected override bool OnKeyDown(KeyDownEvent e) { + if (!HasFocus) + return false; + switch (e.Key) { case Key.Up: @@ -347,6 +350,9 @@ protected override bool OnKeyDown(KeyDownEvent e) public bool OnPressed(KeyBindingPressEvent e) { + if (!HasFocus) + return false; + switch (e.Action) { case PlatformAction.SelectAll: