// 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; using System.Linq; using osu.Framework.Caching; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Threading; using osuTK; using osuTK.Graphics; using osuTK.Input; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Platform; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Framework.Localisation; namespace osu.Framework.Graphics.UserInterface { public abstract class TextBox : TabbableContainer, IHasCurrentValue, IKeyBindingHandler { protected FillFlowContainer TextFlow { get; private set; } protected Container TextContainer { get; private set; } public override bool HandleNonPositionalInput => HasFocus; /// /// Padding to be used within the TextContainer. Requires special handling due to the sideways scrolling of text content. /// protected virtual float LeftRightPadding => 5; public int? LengthLimit; /// /// Whether clipboard copying functionality is allowed. /// protected virtual bool AllowClipboardExport => true; /// /// Whether seeking to word boundaries is allowed. /// protected virtual bool AllowWordNavigation => true; //represents the left/right selection coordinates of the word double clicked on when dragging private int[] doubleClickWord; /// /// Whether this TextBox should accept left and right arrow keys for navigation. /// public virtual bool HandleLeftRightArrows => true; /// /// Check if a character can be added to this TextBox. /// /// The pending character. /// Whether the character is allowed to be added. protected virtual bool CanAddCharacter(char character) => true; private bool readOnly; public bool ReadOnly { get => readOnly; set { readOnly = value; if (readOnly) KillFocus(); } } /// /// Whether the textbox should rescind focus on commit. /// public bool ReleaseFocusOnCommit { get; set; } = true; /// /// Whether a commit should be triggered whenever the textbox loses focus. /// public bool CommitOnFocusLost { get; set; } public override bool CanBeTabbedTo => !ReadOnly; private ITextInputSource textInput; private Clipboard clipboard; private readonly Caret caret; public delegate void OnCommitHandler(TextBox sender, bool newText); /// /// Fired whenever text is committed via a user action. /// This usually happens on pressing enter, but can also be triggered on focus loss automatically, via . /// public event OnCommitHandler OnCommit; private readonly Scheduler textUpdateScheduler = new Scheduler(() => ThreadSafety.IsUpdateThread, null); protected TextBox() { Masking = true; Children = new Drawable[] { TextContainer = new Container { AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Position = new Vector2(LeftRightPadding, 0), Children = new Drawable[] { Placeholder = CreatePlaceholder(), caret = CreateCaret(), TextFlow = new FillFlowContainer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, }, }, }, }; Current.ValueChanged += e => { Text = e.NewValue; }; caret.Hide(); } [BackgroundDependencyLoader] private void load(GameHost host) { textInput = host.GetTextInput(); clipboard = host.GetClipboard(); if (textInput != null) { textInput.OnNewImeComposition += s => { textUpdateScheduler.Add(() => onImeComposition(s)); cursorAndLayout.Invalidate(); }; textInput.OnNewImeResult += s => { textUpdateScheduler.Add(onImeResult); cursorAndLayout.Invalidate(); }; } } public virtual bool OnPressed(PlatformAction action) { if (!HasFocus) return false; if (!HandleLeftRightArrows && (action == PlatformAction.MoveBackwardChar || action == PlatformAction.MoveForwardChar)) return false; switch (action) { // Clipboard case PlatformAction.Cut: case PlatformAction.Copy: if (string.IsNullOrEmpty(SelectedText) || !AllowClipboardExport) return true; clipboard?.SetText(SelectedText); if (action == PlatformAction.Cut) DeleteBy(0); return true; case PlatformAction.Paste: //the text may get pasted into the hidden textbox, so we don't need any direct clipboard interaction here. string pending = textInput?.GetPendingText(); if (string.IsNullOrEmpty(pending)) pending = clipboard?.GetText(); InsertString(pending); return true; case PlatformAction.SelectAll: selectionStart = 0; selectionEnd = text.Length; cursorAndLayout.Invalidate(); return true; // Cursor Manipulation case PlatformAction.MoveBackwardChar: MoveCursorBy(-1); return true; case PlatformAction.MoveForwardChar: MoveCursorBy(1); return true; case PlatformAction.MoveBackwardWord: MoveCursorBy(GetBackwardWordAmount()); return true; case PlatformAction.MoveForwardWord: MoveCursorBy(GetForwardWordAmount()); return true; case PlatformAction.MoveBackwardLine: MoveCursorBy(GetBackwardLineAmount()); return true; case PlatformAction.MoveForwardLine: MoveCursorBy(GetForwardLineAmount()); return true; // Deletion case PlatformAction.DeleteBackwardChar: DeleteBy(-1); return true; case PlatformAction.DeleteForwardChar: DeleteBy(1); return true; case PlatformAction.DeleteBackwardWord: DeleteBy(GetBackwardWordAmount()); return true; case PlatformAction.DeleteForwardWord: DeleteBy(GetForwardWordAmount()); return true; case PlatformAction.DeleteBackwardLine: DeleteBy(GetBackwardLineAmount()); return true; case PlatformAction.DeleteForwardLine: DeleteBy(GetForwardLineAmount()); return true; // Expand selection case PlatformAction.SelectBackwardChar: ExpandSelectionBy(-1); return true; case PlatformAction.SelectForwardChar: ExpandSelectionBy(1); return true; case PlatformAction.SelectBackwardWord: ExpandSelectionBy(GetBackwardWordAmount()); return true; case PlatformAction.SelectForwardWord: ExpandSelectionBy(GetForwardWordAmount()); return true; case PlatformAction.SelectBackwardLine: ExpandSelectionBy(GetBackwardLineAmount()); return true; case PlatformAction.SelectForwardLine: ExpandSelectionBy(GetForwardLineAmount()); return true; } return false; } public virtual void OnReleased(PlatformAction action) { } /// /// Find the word boundary in the backward direction, then return the negative amount of characters. /// protected int GetBackwardWordAmount() { if (!AllowWordNavigation) return -1; int searchPrev = Math.Clamp(selectionEnd - 1, 0, Math.Max(0, Text.Length - 1)); while (searchPrev > 0 && text[searchPrev] == ' ') searchPrev--; int lastSpace = text.LastIndexOf(' ', searchPrev); return lastSpace > 0 ? -(selectionEnd - lastSpace - 1) : -selectionEnd; } /// /// Find the word boundary in the forward direction, then return the positive amount of characters. /// protected int GetForwardWordAmount() { if (!AllowWordNavigation) return 1; int searchNext = Math.Clamp(selectionEnd, 0, Math.Max(0, Text.Length - 1)); while (searchNext < Text.Length && text[searchNext] == ' ') searchNext++; int nextSpace = text.IndexOf(' ', searchNext); return (nextSpace >= 0 ? nextSpace : text.Length) - selectionEnd; } // Currently only single line is supported and line length and text length are the same. protected int GetBackwardLineAmount() => -text.Length; protected int GetForwardLineAmount() => text.Length; /// /// Move the current cursor by the signed . /// protected void MoveCursorBy(int amount) { selectionStart = selectionEnd; cursorAndLayout.Invalidate(); moveSelection(amount, false); } /// /// Expand the current selection by the signed . /// protected void ExpandSelectionBy(int amount) { moveSelection(amount, true); } /// /// If there is a selection, delete the selected text. /// Otherwise, delete characters from the cursor position by the signed . /// A negative amount represents a backward deletion, and a positive amount represents a forward deletion. /// protected void DeleteBy(int amount) { if (selectionLength == 0) selectionEnd = Math.Clamp(selectionStart + amount, 0, text.Length); if (selectionLength > 0) { string removedText = removeSelection(); OnUserTextRemoved(removedText); } } internal override void UpdateClock(IFrameBasedClock clock) { base.UpdateClock(clock); textUpdateScheduler.UpdateClock(Clock); } protected override void Dispose(bool isDisposing) { OnCommit = null; unbindInput(); base.Dispose(isDisposing); } private float textContainerPosX; private string textAtLastLayout = string.Empty; private void updateCursorAndLayout() { Placeholder.Font = Placeholder.Font.With(size: CalculatedTextSize); textUpdateScheduler.Update(); float cursorPos = 0; if (text.Length > 0) cursorPos = getPositionAt(selectionLeft); float cursorPosEnd = getPositionAt(selectionEnd); float? selectionWidth = null; if (selectionLength > 0) selectionWidth = getPositionAt(selectionRight) - cursorPos; float cursorRelativePositionAxesInBox = (cursorPosEnd - textContainerPosX) / DrawWidth; //we only want to reposition the view when the cursor reaches near the extremities. if (cursorRelativePositionAxesInBox < 0.1 || cursorRelativePositionAxesInBox > 0.9) { textContainerPosX = cursorPosEnd - DrawWidth / 2 + LeftRightPadding * 2; } textContainerPosX = Math.Clamp(textContainerPosX, 0, Math.Max(0, TextFlow.DrawWidth - DrawWidth + LeftRightPadding * 2)); TextContainer.MoveToX(LeftRightPadding - textContainerPosX, 300, Easing.OutExpo); if (HasFocus) caret.DisplayAt(new Vector2(cursorPos, 0), selectionWidth); if (textAtLastLayout != text) Current.Value = text; if (textAtLastLayout.Length == 0 || text.Length == 0) { if (text.Length == 0) Placeholder.Show(); else Placeholder.Hide(); } textAtLastLayout = text; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); //have to run this after children flow if (!cursorAndLayout.IsValid) { updateCursorAndLayout(); cursorAndLayout.Validate(); } } private float getPositionAt(int index) { if (index > 0) { if (index < text.Length) return TextFlow.Children[index].DrawPosition.X + TextFlow.DrawPosition.X; var d = TextFlow.Children[index - 1]; return d.DrawPosition.X + d.DrawSize.X + TextFlow.Spacing.X + TextFlow.DrawPosition.X; } return 0; } private int getCharacterClosestTo(Vector2 pos) { pos = Parent.ToSpaceOfOtherDrawable(pos, TextFlow); int i = 0; foreach (Drawable d in TextFlow.Children) { if (d.DrawPosition.X + d.DrawSize.X / 2 > pos.X) break; i++; } return i; } private int selectionStart; private int selectionEnd; private int selectionLength => Math.Abs(selectionEnd - selectionStart); private int selectionLeft => Math.Min(selectionStart, selectionEnd); private int selectionRight => Math.Max(selectionStart, selectionEnd); private readonly Cached cursorAndLayout = new Cached(); private void moveSelection(int offset, bool expand) { if (textInput?.ImeActive == true) return; int oldStart = selectionStart; int oldEnd = selectionEnd; if (expand) selectionEnd = Math.Clamp(selectionEnd + offset, 0, text.Length); else { if (selectionLength > 0 && 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) { OnCaretMoved(expand); cursorAndLayout.Invalidate(); } } /// /// Removes the selected text if a selection persists. /// private string removeSelection() => removeCharacters(selectionLength); /// /// Removes a specified of characters left side of the current position. /// /// /// If a selection persists, must be called instead. /// /// A string of the removed characters. private string removeCharacters(int number = 1) { if (Current.Disabled || text.Length == 0) return string.Empty; int removeStart = Math.Clamp(selectionRight - number, 0, selectionRight); int removeCount = selectionRight - removeStart; if (removeCount == 0) return string.Empty; Debug.Assert(selectionLength == 0 || removeCount == selectionLength); foreach (var d in TextFlow.Children.Skip(removeStart).Take(removeCount).ToArray()) //ToArray since we are removing items from the children in this block. { TextFlow.Remove(d); TextContainer.Add(d); // account for potentially altered height of textbox d.Y = TextFlow.BoundingBox.Y; d.Hide(); d.Expire(); } var removedText = text.Substring(removeStart, removeCount); text = text.Remove(removeStart, removeCount); // Reorder characters depth after removal to avoid ordering issues with newly added characters. for (int i = removeStart; i < TextFlow.Count; i++) TextFlow.ChangeChildDepth(TextFlow[i], getDepthForCharacterIndex(i)); selectionStart = selectionEnd = removeStart; cursorAndLayout.Invalidate(); return removedText; } /// /// Creates a single character. Override and for custom behavior. /// /// The character that this should represent. /// A that represents the character protected virtual Drawable GetDrawableCharacter(char c) => new SpriteText { Text = c.ToString(), Font = new FontUsage(size: CalculatedTextSize) }; protected virtual Drawable AddCharacterToFlow(char c) { // Remove all characters to the right and store them in a local list, // such that their depth can be updated. List charsRight = new List(); foreach (Drawable d in TextFlow.Children.Skip(selectionLeft)) charsRight.Add(d); TextFlow.RemoveRange(charsRight); // Update their depth to make room for the to-be inserted character. int i = selectionLeft; foreach (Drawable d in charsRight) d.Depth = getDepthForCharacterIndex(i++); // Add the character Drawable ch = GetDrawableCharacter(c); ch.Depth = getDepthForCharacterIndex(selectionLeft); TextFlow.Add(ch); // Add back all the previously removed characters TextFlow.AddRange(charsRight); return ch; } private float getDepthForCharacterIndex(int index) => -index; protected float CalculatedTextSize => TextFlow.DrawSize.Y - (TextFlow.Padding.Top + TextFlow.Padding.Bottom); protected void InsertString(string value) => insertString(value); private void insertString(string value, Action drawableCreationParameters = null) { if (string.IsNullOrEmpty(value)) return; if (Current.Disabled) { NotifyInputError(); return; } foreach (char c in value) { if (char.IsControl(c) || !CanAddCharacter(c)) { NotifyInputError(); continue; } if (selectionLength > 0) removeSelection(); if (text.Length + 1 > LengthLimit) { NotifyInputError(); break; } Drawable drawable = AddCharacterToFlow(c); drawable.Show(); drawableCreationParameters?.Invoke(drawable); text = text.Insert(selectionLeft, c.ToString()); selectionStart = selectionEnd = selectionLeft + 1; cursorAndLayout.Invalidate(); } } /// /// Called whenever an invalid character has been entered /// protected abstract void NotifyInputError(); /// /// Invoked when new text is added via user input. /// /// The text which was added. protected virtual void OnUserTextAdded(string added) { } /// /// Invoked when text is removed via user input. /// /// The text which was removed. protected virtual void OnUserTextRemoved(string removed) { } /// /// Invoked whenever a text string has been committed to the textbox. /// /// Whether the current text string is different than the last committed. protected virtual void OnTextCommitted(bool textChanged) { } /// /// Invoked whenever the caret has moved from its position. /// /// Whether the caret is selecting text while moving. protected virtual void OnCaretMoved(bool selecting) { } /// /// Creates a placeholder that shows whenever the textbox is empty. Override or for custom behavior. /// /// The placeholder protected abstract SpriteText CreatePlaceholder(); protected SpriteText Placeholder; public LocalisableString PlaceholderText { get => Placeholder.Text; set => Placeholder.Text = value; } protected abstract Caret CreateCaret(); private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current { get => current.Current; set => current.Current = value; } private string text = string.Empty; public virtual string Text { get => text; set { if (Current.Disabled) return; if (value == text) return; lastCommitText = value ??= string.Empty; if (value.Length == 0) Placeholder.Show(); else Placeholder.Hide(); if (!IsLoaded) Current.Value = text = value; textUpdateScheduler.Add(delegate { int startBefore = selectionStart; selectionStart = selectionEnd = 0; TextFlow?.Clear(); text = string.Empty; InsertString(value); selectionStart = Math.Clamp(startBefore, 0, text.Length); }); cursorAndLayout.Invalidate(); } } public string SelectedText => selectionLength > 0 ? Text.Substring(selectionLeft, selectionLength) : string.Empty; private bool consumingText; /// /// Begin consuming text from an . /// Continues to consume every loop until is called. /// protected void BeginConsumingText() { consumingText = true; Schedule(consumePendingText); } /// /// Stops consuming text from an . /// protected void EndConsumingText() { consumingText = false; } /// /// Consumes any pending characters and adds them to the textbox if not . /// /// Whether any characters were consumed. private void consumePendingText() { string pendingText = textInput?.GetPendingText(); if (!string.IsNullOrEmpty(pendingText) && !ReadOnly) { InsertString(pendingText); OnUserTextAdded(pendingText); } if (consumingText) Schedule(consumePendingText); } #region Input event handling protected override bool OnKeyDown(KeyDownEvent e) { if (textInput?.ImeActive == true || ReadOnly) return true; if (e.ControlPressed || e.SuperPressed || e.AltPressed) return false; // we only care about keys which can result in text output. if (keyProducesCharacter(e.Key)) BeginConsumingText(); switch (e.Key) { case Key.Escape: KillFocus(); return true; case Key.KeypadEnter: case Key.Enter: Commit(); return true; // avoid blocking certain keys which may be used during typing but don't produce characters. case Key.BackSpace: case Key.Delete: return false; } return base.OnKeyDown(e) || consumingText; } private bool keyProducesCharacter(Key key) => (key == Key.Space || key >= Key.Keypad0 && key <= Key.NonUSBackSlash) && key != Key.KeypadEnter; /// /// Removes focus from this if it currently has focus. /// protected virtual void KillFocus() => killFocus(); private string lastCommitText; private void killFocus() { var manager = GetContainingInputManager(); if (manager?.FocusedDrawable == this) manager.ChangeFocus(null); } /// /// Commits current text on this and releases focus if is set. /// protected virtual void Commit() { if (ReleaseFocusOnCommit && HasFocus) { killFocus(); if (CommitOnFocusLost) // the commit will happen as a result of the focus loss. return; } bool isNew = text != lastCommitText; lastCommitText = text; OnTextCommitted(isNew); OnCommit?.Invoke(this, isNew); } protected override void OnKeyUp(KeyUpEvent e) { if (!e.HasAnyKeyPressed) EndConsumingText(); base.OnKeyUp(e); } protected override void OnDrag(DragEvent e) { //if (textInput?.ImeActive == true) return true; if (ReadOnly) return; if (doubleClickWord != null) { //select words at a time if (getCharacterClosestTo(e.MousePosition) > doubleClickWord[1]) { selectionStart = doubleClickWord[0]; selectionEnd = findSeparatorIndex(text, getCharacterClosestTo(e.MousePosition) - 1, 1); selectionEnd = selectionEnd >= 0 ? selectionEnd : text.Length; } else if (getCharacterClosestTo(e.MousePosition) < doubleClickWord[0]) { selectionStart = doubleClickWord[1]; selectionEnd = findSeparatorIndex(text, getCharacterClosestTo(e.MousePosition), -1); selectionEnd = selectionEnd >= 0 ? selectionEnd + 1 : 0; } else { //in the middle selectionStart = doubleClickWord[0]; selectionEnd = doubleClickWord[1]; } cursorAndLayout.Invalidate(); } else { if (text.Length == 0) return; selectionEnd = getCharacterClosestTo(e.MousePosition); if (selectionLength > 0) GetContainingInputManager().ChangeFocus(this); cursorAndLayout.Invalidate(); } } protected override bool OnDragStart(DragStartEvent e) { if (HasFocus) return true; Vector2 posDiff = e.MouseDownPosition - e.MousePosition; return Math.Abs(posDiff.X) > Math.Abs(posDiff.Y); } protected override bool OnDoubleClick(DoubleClickEvent e) { if (textInput?.ImeActive == true) return true; if (text.Length == 0) return true; if (AllowClipboardExport) { int hover = Math.Min(text.Length - 1, getCharacterClosestTo(e.MousePosition)); int lastSeparator = findSeparatorIndex(text, hover, -1); int nextSeparator = findSeparatorIndex(text, hover, 1); selectionStart = lastSeparator >= 0 ? lastSeparator + 1 : 0; selectionEnd = nextSeparator >= 0 ? nextSeparator : text.Length; } else { selectionStart = 0; selectionEnd = text.Length; } //in order to keep the home word selected doubleClickWord = new[] { selectionStart, selectionEnd }; cursorAndLayout.Invalidate(); return true; } private static int findSeparatorIndex(string input, int searchPos, int direction) { bool isLetterOrDigit = char.IsLetterOrDigit(input[searchPos]); for (int i = searchPos; i >= 0 && i < input.Length; i += direction) { if (char.IsLetterOrDigit(input[i]) != isLetterOrDigit) return i; } return -1; } protected override bool OnMouseDown(MouseDownEvent e) { if (textInput?.ImeActive == true || ReadOnly) return true; selectionStart = selectionEnd = getCharacterClosestTo(e.MousePosition); cursorAndLayout.Invalidate(); return false; } protected override void OnMouseUp(MouseUpEvent e) { doubleClickWord = null; } protected override void OnFocusLost(FocusLostEvent e) { unbindInput(); caret.Hide(); cursorAndLayout.Invalidate(); if (CommitOnFocusLost) Commit(); } public override bool AcceptsFocus => true; protected override bool OnClick(ClickEvent e) { if (!ReadOnly && HasFocus) textInput?.EnsureActivated(); return !ReadOnly; } protected override void OnFocus(FocusEvent e) { bindInput(); caret.Show(); cursorAndLayout.Invalidate(); } #endregion #region Native TextBox handling (platform-specific) private void unbindInput() { textInput?.Deactivate(); } private void bindInput() { textInput?.Activate(); } private void onImeResult() { //we only succeeded if there is pending data in the textbox if (imeDrawables.Count > 0) { foreach (var d in imeDrawables) { d.Colour = Color4.White; d.FadeTo(1, 200, Easing.Out); } } imeDrawables.Clear(); } private readonly List imeDrawables = new List(); private void onImeComposition(string s) { //search for unchanged characters.. int matchCount = 0; bool matching = true; int searchStart = text.Length - imeDrawables.Count; for (int i = 0; i < s.Length; i++) { if (matching && searchStart + i < text.Length && i < s.Length && text[searchStart + i] == s[i]) { matchCount = i + 1; continue; } matching = false; } var unmatchingCount = imeDrawables.Count - matchCount; if (unmatchingCount > 0) { removeCharacters(unmatchingCount); imeDrawables.RemoveRange(matchCount, unmatchingCount); } if (matchCount == s.Length) //in the case of backspacing (or a NOP), we can exit early here. return; string insertedText = s.Substring(matchCount); insertString(insertedText, d => { d.Colour = Color4.Aqua; d.Alpha = 0.6f; imeDrawables.Add(d); }); OnUserTextAdded(insertedText); } #endregion } }