mirror of
https://github.com/SK-la/osu-framework.git
synced 2026-03-15 03:20:30 +00:00
1776 lines
65 KiB
C#
1776 lines
65 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using JetBrains.Annotations;
|
|
using osu.Framework.Allocation;
|
|
using osu.Framework.Bindables;
|
|
using osu.Framework.Caching;
|
|
using osu.Framework.Development;
|
|
using osu.Framework.Extensions.ObjectExtensions;
|
|
using osu.Framework.Extensions.PlatformActionExtensions;
|
|
using osu.Framework.Graphics.Containers;
|
|
using osu.Framework.Graphics.Primitives;
|
|
using osu.Framework.Graphics.Sprites;
|
|
using osu.Framework.Input;
|
|
using osu.Framework.Input.Bindings;
|
|
using osu.Framework.Input.Events;
|
|
using osu.Framework.Localisation;
|
|
using osu.Framework.Platform;
|
|
using osu.Framework.Threading;
|
|
using osuTK;
|
|
using osuTK.Input;
|
|
|
|
namespace osu.Framework.Graphics.UserInterface
|
|
{
|
|
public abstract partial class TextBox : TabbableContainer, IHasCurrentValue<string>, IKeyBindingHandler<PlatformAction>, ICanSuppressKeyEventLogging
|
|
{
|
|
protected FillFlowContainer TextFlow { get; private set; }
|
|
protected Container TextContainer { get; private set; }
|
|
|
|
public override bool HandleNonPositionalInput => HasFocus;
|
|
|
|
/// <summary>
|
|
/// A character displayed whenever the type of text input set by <see cref="TextInputProperties.Type"/> is hidden.
|
|
/// </summary>
|
|
protected virtual char MaskCharacter => '*';
|
|
|
|
/// <summary>
|
|
/// Padding to be used within the TextContainer. Requires special handling due to the sideways scrolling of text content.
|
|
/// </summary>
|
|
protected virtual float LeftRightPadding => 5;
|
|
|
|
/// <summary>
|
|
/// Maximum allowed length of text.
|
|
/// </summary>
|
|
/// <remarks>Any input beyond this limit will be dropped and then <see cref="NotifyInputError"/> will be called.</remarks>
|
|
public int? LengthLimit;
|
|
|
|
/// <summary>
|
|
/// Whether clipboard copying functionality is allowed.
|
|
/// </summary>
|
|
protected virtual bool AllowClipboardExport => !InputProperties.Type.IsPassword();
|
|
|
|
/// <summary>
|
|
/// Whether seeking to word boundaries is allowed.
|
|
/// </summary>
|
|
protected virtual bool AllowWordNavigation => !InputProperties.Type.IsPassword();
|
|
|
|
bool ICanSuppressKeyEventLogging.SuppressKeyEventLogging => InputProperties.Type.IsPassword();
|
|
|
|
/// <summary>
|
|
/// Represents the left/right selection coordinates of the word double clicked on when dragging.
|
|
/// </summary>
|
|
private int[] doubleClickWord;
|
|
|
|
/// <summary>
|
|
/// Whether this TextBox should accept left and right arrow keys for navigation.
|
|
/// </summary>
|
|
public virtual bool HandleLeftRightArrows => true;
|
|
|
|
[Obsolete($"Use {nameof(InputProperties)} instead.")] // can be removed 20250506
|
|
protected virtual bool AllowIme => true;
|
|
|
|
/// <summary>
|
|
/// A set of properties to consider when interacting with this <see cref="TextBox"/>.
|
|
/// </summary>
|
|
public TextInputProperties InputProperties { get; init; }
|
|
|
|
/// <summary>
|
|
/// Check if a character can be added to this TextBox.
|
|
/// </summary>
|
|
/// <param name="character">The pending character.</param>
|
|
/// <returns>Whether the character is allowed to be added.</returns>
|
|
protected virtual bool CanAddCharacter(char character) => true;
|
|
|
|
/// <summary>
|
|
/// Private helper for <see cref="CanAddCharacter"/>, additionally requiring that the character is not a control character and obeys <see cref="TextInputProperties.Type"/>.
|
|
/// </summary>
|
|
private bool canAddCharacter(char character)
|
|
{
|
|
return !char.IsControl(character)
|
|
&& (!InputProperties.Type.IsNumerical() || char.IsAsciiDigit(character))
|
|
&& CanAddCharacter(character);
|
|
}
|
|
|
|
private bool readOnly;
|
|
|
|
public bool ReadOnly
|
|
{
|
|
get => readOnly;
|
|
set
|
|
{
|
|
readOnly = value;
|
|
|
|
if (readOnly)
|
|
KillFocus();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the textbox should rescind focus on commit.
|
|
/// </summary>
|
|
public bool ReleaseFocusOnCommit { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether a commit should be triggered whenever the textbox loses focus.
|
|
/// </summary>
|
|
public bool CommitOnFocusLost { get; set; }
|
|
|
|
public override bool CanBeTabbedTo => !ReadOnly;
|
|
|
|
[Resolved]
|
|
private TextInputSource textInput { get; set; }
|
|
|
|
[Resolved]
|
|
private Clipboard clipboard { get; set; }
|
|
|
|
/// <summary>
|
|
/// Whether the <see cref="GameHost"/> is active (has keyboard focus).
|
|
/// </summary>
|
|
private IBindable<bool> isActive;
|
|
|
|
private readonly Caret caret;
|
|
|
|
public delegate void OnCommitHandler(TextBox sender, bool newText);
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="CommitOnFocusLost"/>.
|
|
/// </summary>
|
|
public event OnCommitHandler OnCommit;
|
|
|
|
/// <summary>
|
|
/// Scheduler used for scheduling text input events coming from <see cref="textInput"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Used for scheduling text events so that the <see cref="Text"/> is updated on the update thread.
|
|
/// This scheduler is updated in two places / at two points in time:
|
|
/// - Early in the update frame, in <see cref="OnKeyDown"/>, so that the key event is blocked. We assume a key event that comes right after a
|
|
/// text event is associated with that text event and therefore should be blocked. In other words: to ensure consistent UX, if a user
|
|
/// presses a key to input text then no other action (eg. from a keyboard shortcut) should be taken by the game, so we block it.
|
|
/// - Later in the same update frame, in <see cref="Update"/>. In case there was no associated key event. This is mostly required for mobile platforms.
|
|
/// </remarks>
|
|
private readonly Scheduler textInputScheduler = new Scheduler(() => ThreadSafety.IsUpdateThread, null);
|
|
|
|
/// <summary>
|
|
/// Scheduler used for scheduling IME composition and result events coming from <see cref="textInput"/>.
|
|
/// </summary>
|
|
private readonly Scheduler imeCompositionScheduler = new Scheduler(() => ThreadSafety.IsUpdateThread, null);
|
|
|
|
protected TextBox()
|
|
{
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
InputProperties = new TextInputProperties(TextInputType.Text, AllowIme);
|
|
#pragma warning restore CS0618 // Type or member is obsolete
|
|
|
|
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().With(p =>
|
|
{
|
|
p.Anchor = Anchor.CentreLeft;
|
|
p.Origin = Anchor.CentreLeft;
|
|
}),
|
|
caret = CreateCaret().With(c =>
|
|
{
|
|
c.Anchor = Anchor.CentreLeft;
|
|
c.Origin = Anchor.CentreLeft;
|
|
}),
|
|
TextFlow = new FillFlowContainer
|
|
{
|
|
Anchor = Anchor.CentreLeft,
|
|
Origin = Anchor.CentreLeft,
|
|
Direction = FillDirection.Horizontal,
|
|
AutoSizeAxes = Axes.Both,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
Current.ValueChanged += e =>
|
|
{
|
|
// we generally want Text and Current to be synchronised at all times.
|
|
// a change to Text will trigger a Current set, and potentially cause a feedback loop which isn't always desirable
|
|
// (could lead to no animations playing out, etc.)
|
|
// the following guard is supposed to not allow that feedback loop to close.
|
|
if (Text != e.NewValue)
|
|
Text = e.NewValue;
|
|
};
|
|
|
|
caretVisible = false;
|
|
caret.Hide();
|
|
}
|
|
|
|
[BackgroundDependencyLoader]
|
|
private void load(GameHost host)
|
|
{
|
|
isActive = host.IsActive.GetBoundCopy();
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
isActive.BindValueChanged(_ => Scheduler.AddOnce(updateCaretVisibility));
|
|
Current.BindDisabledChanged(disabled =>
|
|
{
|
|
if (disabled)
|
|
{
|
|
// disabling Current means that the textbox shouldn't accept any more user input.
|
|
// if there is currently an ongoing composition, we want to finalize it and reset the user's IME
|
|
// so that the user understands that compositing is done and that further input won't be accepted.
|
|
FinalizeImeComposition(false);
|
|
}
|
|
});
|
|
|
|
setText(Text);
|
|
}
|
|
|
|
public virtual bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
|
|
{
|
|
if (!HasFocus)
|
|
return false;
|
|
|
|
if (!HandleLeftRightArrows && (e.Action == PlatformAction.MoveBackwardChar || e.Action == PlatformAction.MoveForwardChar))
|
|
return false;
|
|
|
|
if (e.Action.IsCommonTextEditingAction() && ImeCompositionActive)
|
|
return true;
|
|
|
|
var lastSelectionBounds = getTextSelectionBounds();
|
|
|
|
switch (e.Action)
|
|
{
|
|
// Clipboard
|
|
case PlatformAction.Cut:
|
|
case PlatformAction.Copy:
|
|
if (!AllowClipboardExport) return false;
|
|
|
|
if (!string.IsNullOrEmpty(SelectedText))
|
|
{
|
|
clipboard.SetText(SelectedText);
|
|
|
|
if (e.Action == PlatformAction.Cut)
|
|
DeleteBy(0);
|
|
}
|
|
|
|
return true;
|
|
|
|
case PlatformAction.Paste:
|
|
if (textInputBlocking)
|
|
// TextInputSource received text while this action got activated.
|
|
// This is an indicator that text has already been pasted at an OS level
|
|
// and has been received here through the TextInputSource flow.
|
|
//
|
|
// This is currently only happening on iOS since it relies on a hidden UITextField for software keyboard.
|
|
return true;
|
|
|
|
InsertString(clipboard.GetText());
|
|
return true;
|
|
|
|
case PlatformAction.SelectAll:
|
|
SelectAll();
|
|
onTextSelectionChanged(TextSelectionType.All, lastSelectionBounds);
|
|
return true;
|
|
|
|
// Cursor Manipulation
|
|
case PlatformAction.MoveBackwardChar:
|
|
if (hasSelection)
|
|
{
|
|
MoveCursorBy(selectionLeft - selectionEnd);
|
|
}
|
|
else
|
|
{
|
|
MoveCursorBy(-1);
|
|
}
|
|
|
|
return true;
|
|
|
|
case PlatformAction.MoveForwardChar:
|
|
if (hasSelection)
|
|
{
|
|
MoveCursorBy(selectionRight - selectionEnd);
|
|
}
|
|
else
|
|
{
|
|
MoveCursorBy(1);
|
|
}
|
|
|
|
return true;
|
|
|
|
case PlatformAction.MoveBackwardWord:
|
|
if (hasSelection)
|
|
{
|
|
MoveCursorBy(selectionLeft - selectionEnd);
|
|
}
|
|
else
|
|
{
|
|
MoveCursorBy(GetBackwardWordAmount());
|
|
}
|
|
|
|
return true;
|
|
|
|
case PlatformAction.MoveForwardWord:
|
|
if (hasSelection)
|
|
{
|
|
MoveCursorBy(selectionRight - selectionEnd);
|
|
}
|
|
else
|
|
{
|
|
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);
|
|
onTextSelectionChanged(TextSelectionType.Character, lastSelectionBounds);
|
|
return true;
|
|
|
|
case PlatformAction.SelectForwardChar:
|
|
ExpandSelectionBy(1);
|
|
onTextSelectionChanged(TextSelectionType.Character, lastSelectionBounds);
|
|
return true;
|
|
|
|
case PlatformAction.SelectBackwardWord:
|
|
ExpandSelectionBy(GetBackwardWordAmount());
|
|
onTextSelectionChanged(TextSelectionType.Word, lastSelectionBounds);
|
|
return true;
|
|
|
|
case PlatformAction.SelectForwardWord:
|
|
ExpandSelectionBy(GetForwardWordAmount());
|
|
onTextSelectionChanged(TextSelectionType.Word, lastSelectionBounds);
|
|
return true;
|
|
|
|
case PlatformAction.SelectBackwardLine:
|
|
ExpandSelectionBy(GetBackwardLineAmount());
|
|
// TODO: Differentiate 'line' and 'all' selection types if/when multi-line support is added
|
|
onTextSelectionChanged(TextSelectionType.All, lastSelectionBounds);
|
|
return true;
|
|
|
|
case PlatformAction.SelectForwardLine:
|
|
ExpandSelectionBy(GetForwardLineAmount());
|
|
// TODO: Differentiate 'line' and 'all' selection types if/when multi-line support is added
|
|
onTextSelectionChanged(TextSelectionType.All, lastSelectionBounds);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public virtual void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects all text in this <see cref="TextBox"/>. Focus must be acquired before calling this method.
|
|
/// </summary>
|
|
/// <returns>Whether text has been selected successfully. Returns <c>false</c> if the text box does not have focus.</returns>
|
|
public bool SelectAll()
|
|
{
|
|
if (!HasFocus)
|
|
return false;
|
|
|
|
selectionStart = 0;
|
|
selectionEnd = text.Length;
|
|
cursorAndLayout.Invalidate();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find the word boundary in the backward direction, then return the negative amount of characters.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find the word boundary in the forward direction, then return the positive amount of characters.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Move the current cursor by the signed <paramref name="amount"/>.
|
|
/// </summary>
|
|
protected void MoveCursorBy(int amount)
|
|
{
|
|
var lastSelectionBounds = getTextSelectionBounds();
|
|
selectionStart = selectionEnd;
|
|
cursorAndLayout.Invalidate();
|
|
moveSelection(amount, false);
|
|
onTextDeselected(lastSelectionBounds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Expand the current selection by the signed <paramref name="amount"/>.
|
|
/// </summary>
|
|
protected void ExpandSelectionBy(int amount)
|
|
{
|
|
moveSelection(amount, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// If there is a selection, delete the selected text.
|
|
/// Otherwise, delete characters from the cursor position by the signed <paramref name="amount"/>.
|
|
/// A negative amount represents a backward deletion, and a positive amount represents a forward deletion.
|
|
/// </summary>
|
|
protected void DeleteBy(int amount)
|
|
{
|
|
if (selectionLength == 0)
|
|
selectionEnd = Math.Clamp(selectionStart + amount, 0, text.Length);
|
|
|
|
if (hasSelection)
|
|
{
|
|
string removedText = removeSelection();
|
|
OnUserTextRemoved(removedText);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finalize the current IME composition if one is active.
|
|
/// </summary>
|
|
/// <param name="userEvent">
|
|
/// Whether this was invoked from a user action.
|
|
/// Set to <c>true</c> to have <see cref="OnImeResult"/> invoked.
|
|
/// </param>
|
|
/// <remarks>Must only be called from the update thread.</remarks>
|
|
protected void FinalizeImeComposition(bool userEvent)
|
|
{
|
|
// do nothing if there isn't an active composition.
|
|
// importantly, if there are pending tasks, we should finish those off regardless
|
|
// and then call `onImeResult()`.
|
|
// the composition being inactive and having scheduled tasks shouldn't happen,
|
|
// but the check is here to cover that improbable edge case.
|
|
if (!ImeCompositionActive && !imeCompositionScheduler.HasPendingTasks)
|
|
return;
|
|
|
|
imeCompositionScheduler.Add(() => onImeResult(userEvent, false));
|
|
|
|
if (textInputBound)
|
|
textInput.ResetIme();
|
|
|
|
// importantly, we want to force-update all pending composition events,
|
|
// so that when we return control to the caller, those events won't mutate text and/or caret position.
|
|
imeCompositionScheduler.Update();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels the current IME composition, removing it from the <see cref="Text"/>.
|
|
/// </summary>
|
|
/// <remarks>Must only be called from the update thread.</remarks>
|
|
protected void CancelImeComposition()
|
|
{
|
|
// same rationale as above, in `FinalizeImeComposition()`
|
|
if (!ImeCompositionActive && !imeCompositionScheduler.HasPendingTasks)
|
|
return;
|
|
|
|
if (textInputBound)
|
|
textInput.ResetIme();
|
|
|
|
imeCompositionScheduler.Add(() => onImeComposition(string.Empty, 0, 0, false));
|
|
imeCompositionScheduler.Update(); // same rationale as above, in `FinalizeImeComposition()`
|
|
}
|
|
|
|
protected override void Dispose(bool isDisposing)
|
|
{
|
|
OnCommit = null;
|
|
|
|
unbindInput(null);
|
|
|
|
base.Dispose(isDisposing);
|
|
}
|
|
|
|
private float textContainerPosX;
|
|
|
|
private string textAtLastLayout = string.Empty;
|
|
|
|
private void updateCursorAndLayout()
|
|
{
|
|
caret.Height = FontSize;
|
|
Placeholder.Font = Placeholder.Font.With(size: FontSize);
|
|
|
|
float cursorPos = 0;
|
|
if (text.Length > 0)
|
|
cursorPos = getPositionAt(selectionLeft);
|
|
|
|
float cursorPosEnd = getPositionAt(selectionEnd);
|
|
|
|
float? selectionWidth = null;
|
|
if (hasSelection)
|
|
selectionWidth = getPositionAt(selectionRight) - cursorPos;
|
|
|
|
float cursorRelativePositionAxesInBox = (cursorPosEnd - textContainerPosX) / (DrawWidth - 2 * LeftRightPadding);
|
|
|
|
//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 (caretVisible)
|
|
caret.DisplayAt(new Vector2(cursorPos, 0), selectionWidth);
|
|
|
|
if (textAtLastLayout.Length == 0 || text.Length == 0)
|
|
{
|
|
if (text.Length == 0)
|
|
Placeholder.Show();
|
|
else
|
|
Placeholder.Hide();
|
|
}
|
|
|
|
textAtLastLayout = text;
|
|
}
|
|
|
|
protected override void Update()
|
|
{
|
|
base.Update();
|
|
|
|
// update the schedulers before updating children as it might mutate TextFlow.
|
|
// we want the character drawables to be up-to date for further calculations in `updateCursorAndLayout()`.
|
|
textInputScheduler.Update();
|
|
imeCompositionScheduler.Update();
|
|
}
|
|
|
|
protected override void UpdateAfterChildren()
|
|
{
|
|
base.UpdateAfterChildren();
|
|
|
|
// have to run this after children flow
|
|
if (!cursorAndLayout.IsValid)
|
|
{
|
|
// update in case selection length has changed.
|
|
updateCaretVisibility();
|
|
|
|
updateCursorAndLayout();
|
|
cursorAndLayout.Validate();
|
|
|
|
// keep the IME window up-to date with the current selection / composition string.
|
|
updateImeWindowPosition();
|
|
}
|
|
}
|
|
|
|
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 bool hasSelection => selectionLength > 0;
|
|
|
|
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) return;
|
|
|
|
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)
|
|
{
|
|
OnCaretMoved(expand);
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Indicates whether a complex change operation to <see cref="Text"/> has begun.
|
|
/// This is relevant because, for example, an insertion operation with text selected is really a removal of the selection and an insertion.
|
|
/// We want to ensure that <see cref="Text"/> is transferred out to <see cref="Current"/> only at the end of such an operation chain.
|
|
/// </summary>
|
|
private bool textChanging;
|
|
|
|
/// <summary>
|
|
/// Starts a text change operation.
|
|
/// </summary>
|
|
/// <returns>Whether this call has initiated a text change.</returns>
|
|
private bool beginTextChange()
|
|
{
|
|
if (textChanging)
|
|
return false;
|
|
|
|
return textChanging = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ends a text change operation.
|
|
/// This causes <see cref="Text"/> to be transferred out to <see cref="Current"/>.
|
|
/// </summary>
|
|
/// <param name="started">The return value of a corresponding <see cref="beginTextChange"/> call should be passed here.</param>
|
|
private void endTextChange(bool started)
|
|
{
|
|
if (!started)
|
|
return;
|
|
|
|
if (Current.Value != Text)
|
|
Current.Value = Text;
|
|
|
|
textChanging = false;
|
|
}
|
|
|
|
private bool ignoreOngoingDragSelection;
|
|
|
|
/// <summary>
|
|
/// Removes the selected text if a selection persists.
|
|
/// </summary>
|
|
private string removeSelection() => removeCharacters(selectionLength);
|
|
|
|
/// <summary>
|
|
/// Removes a specified <paramref name="number"/> of characters left side of the current position.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// If a selection persists, <see cref="removeSelection"/> must be called instead.
|
|
/// </remarks>
|
|
/// <returns>A string of the removed characters.</returns>
|
|
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);
|
|
|
|
bool beganChange = beginTextChange();
|
|
|
|
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, false);
|
|
|
|
TextContainer.Add(d);
|
|
|
|
// account for potentially altered height of textbox
|
|
d.Y = TextFlow.BoundingBox.Y;
|
|
|
|
d.Hide();
|
|
d.Expire();
|
|
}
|
|
|
|
string 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;
|
|
doubleClickWord = null;
|
|
|
|
endTextChange(beganChange);
|
|
cursorAndLayout.Invalidate();
|
|
|
|
return removedText;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a single character. Override <see cref="Drawable.Show"/> and <see cref="Drawable.Hide"/> for custom behavior.
|
|
/// </summary>
|
|
/// <param name="c">The character that this <see cref="Drawable"/> should represent.</param>
|
|
/// <returns>A <see cref="Drawable"/> that represents the character <paramref name="c"/> </returns>
|
|
protected virtual Drawable GetDrawableCharacter(char c) => new SpriteText { Text = c.ToString(), Font = new FontUsage(size: FontSize) };
|
|
|
|
protected virtual Drawable AddCharacterToFlow(char c)
|
|
{
|
|
if (InputProperties.Type.IsPassword())
|
|
c = MaskCharacter;
|
|
|
|
// Remove all characters to the right and store them in a local list,
|
|
// such that their depth can be updated.
|
|
List<Drawable> charsRight = new List<Drawable>();
|
|
foreach (Drawable d in TextFlow.Children.Skip(selectionLeft))
|
|
charsRight.Add(d);
|
|
TextFlow.RemoveRange(charsRight, false);
|
|
|
|
// 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;
|
|
|
|
private readonly float? customFontSize;
|
|
|
|
/// <summary>
|
|
/// A fixed size for the text displayed in this <see cref="TextBox"/>. If left unset, text size will be computed based on the dimensions of the <see cref="TextBox"/>.
|
|
/// </summary>
|
|
public float FontSize
|
|
{
|
|
get => customFontSize ?? TextContainer.DrawSize.Y;
|
|
init => customFontSize = value;
|
|
}
|
|
|
|
protected void InsertString(string value)
|
|
{
|
|
// inserting text could insert it in the middle of an active composition, leading to an invalid state.
|
|
// so finalize the composition before adding text.
|
|
FinalizeImeComposition(false);
|
|
|
|
insertString(value);
|
|
}
|
|
|
|
private void insertString(string value, Action<Drawable> drawableCreationParameters = null)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return;
|
|
|
|
if (Current.Disabled)
|
|
{
|
|
NotifyInputError();
|
|
return;
|
|
}
|
|
|
|
bool beganChange = beginTextChange();
|
|
|
|
foreach (char c in value)
|
|
{
|
|
if (!canAddCharacter(c))
|
|
{
|
|
NotifyInputError();
|
|
continue;
|
|
}
|
|
|
|
if (hasSelection)
|
|
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;
|
|
ignoreOngoingDragSelection = true;
|
|
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
|
|
endTextChange(beganChange);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called whenever an invalid character has been entered
|
|
/// </summary>
|
|
protected abstract void NotifyInputError();
|
|
|
|
/// <summary>
|
|
/// Invoked when new text is added via user input.
|
|
/// </summary>
|
|
/// <param name="added">The text which was added.</param>
|
|
protected virtual void OnUserTextAdded(string added)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked when text is removed via user input.
|
|
/// </summary>
|
|
/// <param name="removed">The text which was removed.</param>
|
|
protected virtual void OnUserTextRemoved(string removed)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked whenever a text string has been committed to the textbox.
|
|
/// </summary>
|
|
/// <param name="textChanged">Whether the current text string is different than the last committed.</param>
|
|
protected virtual void OnTextCommitted(bool textChanged)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked whenever the caret has moved from its position.
|
|
/// </summary>
|
|
/// <param name="selecting">Whether the caret is selecting text while moving.</param>
|
|
protected virtual void OnCaretMoved(bool selecting)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked whenever text selection changes. For deselection, see <seealso cref="OnTextDeselected"/>.
|
|
/// </summary>
|
|
/// <param name="selectionType">The type of selection change that occured.</param>
|
|
protected virtual void OnTextSelectionChanged(TextSelectionType selectionType)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked whenever selected text is deselected. For selection, see <seealso cref="OnTextSelectionChanged"/>.
|
|
/// </summary>
|
|
protected virtual void OnTextDeselected()
|
|
{
|
|
}
|
|
|
|
private void onTextSelectionChanged(TextSelectionType selectionType, (int start, int end) lastSelectionBounds)
|
|
{
|
|
if (lastSelectionBounds.start == selectionStart && lastSelectionBounds.end == selectionEnd)
|
|
return;
|
|
|
|
if (hasSelection)
|
|
OnTextSelectionChanged(selectionType);
|
|
else
|
|
onTextDeselected(lastSelectionBounds);
|
|
}
|
|
|
|
private void onTextDeselected((int start, int end) lastSelectionBounds)
|
|
{
|
|
if (lastSelectionBounds.start == selectionStart && lastSelectionBounds.end == selectionEnd)
|
|
return;
|
|
|
|
if (lastSelectionBounds.start != lastSelectionBounds.end)
|
|
OnTextDeselected();
|
|
}
|
|
|
|
private (int start, int end) getTextSelectionBounds() => (selectionStart, selectionEnd);
|
|
|
|
/// <summary>
|
|
/// Invoked whenever the IME composition has changed.
|
|
/// </summary>
|
|
/// <param name="newComposition">The current text of the composition.</param>
|
|
/// <param name="removedTextLength">The number of characters that have been replaced by new ones.</param>
|
|
/// <param name="addedTextLength">The number of characters that have replaced the old ones.</param>
|
|
/// <param name="selectionMoved">Whether the selection/caret has moved.</param>
|
|
protected virtual void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool selectionMoved)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked when the IME has finished compositing.
|
|
/// </summary>
|
|
/// <param name="result">The result of the composition.</param>
|
|
/// <param name="successful">
|
|
/// Whether this composition was finished trough normal means (eg. user normally finished compositing trough the IME).
|
|
/// <c>false</c> if ended prematurely.
|
|
/// </param>
|
|
protected virtual void OnImeResult(string result, bool successful)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a placeholder that shows whenever the textbox is empty. Override <see cref="Drawable.Show"/> or <see cref="Drawable.Hide"/> for custom behavior.
|
|
/// </summary>
|
|
/// <returns>The placeholder</returns>
|
|
protected abstract SpriteText CreatePlaceholder();
|
|
|
|
protected SpriteText Placeholder;
|
|
|
|
public LocalisableString PlaceholderText
|
|
{
|
|
get => Placeholder.Text;
|
|
set => Placeholder.Text = value;
|
|
}
|
|
|
|
protected abstract Caret CreateCaret();
|
|
|
|
/// <summary>
|
|
/// Whether the <see cref="caret"/> should be visible.
|
|
/// </summary>
|
|
private bool caretVisible;
|
|
|
|
private void updateCaretVisibility()
|
|
{
|
|
// a blinking cursor signals to the user that keyboard input will appear at that cursor,
|
|
// hide the caret when we don't have keyboard focus to conform with that expectation.
|
|
// importantly, we want the caret to remain visible when there is a selection.
|
|
bool newVisibility = HasFocus && (isActive.Value || selectionLength != 0);
|
|
|
|
if (caretVisible != newVisibility)
|
|
{
|
|
caretVisible = newVisibility;
|
|
|
|
if (caretVisible)
|
|
caret.Show();
|
|
else
|
|
caret.Hide();
|
|
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
}
|
|
|
|
private readonly BindableWithCurrent<string> current = new BindableWithCurrent<string>(string.Empty);
|
|
|
|
public Bindable<string> 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();
|
|
|
|
setText(value);
|
|
}
|
|
}
|
|
|
|
private void setText(string value)
|
|
{
|
|
bool beganChange = beginTextChange();
|
|
|
|
// finalize and cleanup the IME composition (if one is active) so we get a clean slate for pending text changes and future IME composition.
|
|
// `IsLoaded` check is required because `Text` could be set in the initializer / before the drawable loaded.
|
|
// `FinalizeImeComposition()` crashes if textbox isn't fully loaded.
|
|
if (IsLoaded) FinalizeImeComposition(false);
|
|
|
|
selectionStart = selectionEnd = 0;
|
|
|
|
TextFlow?.Clear();
|
|
text = string.Empty;
|
|
|
|
// insert string and fast forward any transforms (generally when replacing the full content of a textbox we don't want any kind of fade etc.).
|
|
insertString(value, d => d.FinishTransforms());
|
|
|
|
endTextChange(beganChange);
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
|
|
public string SelectedText => hasSelection ? Text.Substring(selectionLeft, selectionLength) : string.Empty;
|
|
|
|
/// <summary>
|
|
/// Whether <see cref="KeyDownEvent"/>s should be blocked because of recent text input from a <see cref="TextInputSource"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Blocking starts when a text events occurs and ends when all keys are released (or on the next frame if no keys are pressed).
|
|
///
|
|
/// We currently eagerly block keydown events, blocking all key events until all keys are released.
|
|
/// This means that some key events will be (erroneously) blocked, even if they weren't associated with a text event.
|
|
/// This simplified logic is used because trying to associate each key event with a text event is error prone.
|
|
/// Some reasons as to why:
|
|
/// - Text and key repeat rate are inherently different, since text repeat is handled by the OS, while <see cref="InputManager"/> handles key repeat.
|
|
/// - The ordering of keydown and text events can vary between platforms.
|
|
/// - The ordering of the events can vary even more because these events are propagated to the textbox differently:
|
|
/// - Key events are propagated by <see cref="UserInputManager"/> at the beginning of each update frame.
|
|
/// - Text events are propagated immediately when they're received, and are handled either by the <see cref="Update"/> call or <see cref="OnKeyDown"/>,
|
|
/// whichever comes first. (Check usages of <see cref="textInputScheduler"/>.<see cref="Scheduler.Update"/> for specifics.)
|
|
/// - This is especially problematic if the key and text events arrive in between the <see cref="UserInputManager"/> and <see cref="TextBox"/> updates.
|
|
///
|
|
/// So we catch the first key that produced text and block until we get back to a sane state (all keys released).
|
|
/// </remarks>
|
|
private bool textInputBlocking;
|
|
|
|
/// <summary>
|
|
/// Whether there is a ongoing IME composition.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The IME should take full input priority, as a lot of the common text editing keys/shortcuts
|
|
/// are used internally in the IME for compositing.
|
|
/// Full data about the composition events is processed by <see cref="handleImeComposition"/> "passively"
|
|
/// so we shouldn't take any action on key events we receive.
|
|
/// </remarks>
|
|
protected bool ImeCompositionActive => (textInputBound && textInput.ImeActive) || imeCompositionLength > 0;
|
|
|
|
#region Input event handling
|
|
|
|
protected override bool OnKeyDown(KeyDownEvent e)
|
|
{
|
|
if (readOnly)
|
|
return true;
|
|
|
|
if (ImeCompositionActive)
|
|
return true;
|
|
|
|
switch (e.Key)
|
|
{
|
|
case Key.Escape:
|
|
// if keypress is repeating, the IME was probably closed with the first, non-repeating keypress
|
|
// so don't kill focus unless the user has explicitly released and pressed the key again.
|
|
if (!e.Repeat)
|
|
KillFocus();
|
|
return true;
|
|
|
|
case Key.KeypadEnter:
|
|
case Key.Enter:
|
|
// alt-enter is commonly used to toggle fullscreen.
|
|
if (e.AltPressed)
|
|
return false;
|
|
|
|
// same rationale as comment in case statement above.
|
|
if (!e.Repeat)
|
|
Commit();
|
|
return true;
|
|
|
|
// avoid blocking certain keys which we need propagated to a PlatformActionContainer,
|
|
// so that we can get them as appropriate `PlatformAction`s in OnPressed(KeyBindingPressEvent<PlatformAction>).
|
|
case Key.BackSpace:
|
|
case Key.Delete:
|
|
return false;
|
|
}
|
|
|
|
// check for any pending text input.
|
|
// updating here will set `textInputBlocking` accordingly.
|
|
textInputScheduler.Update();
|
|
|
|
// block on recent text input *after* handling the above keys so those keys can be used during text input.
|
|
return base.OnKeyDown(e) || textInputBlocking;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes focus from this <see cref="TextBox"/> if it currently has focus.
|
|
/// </summary>
|
|
protected virtual void KillFocus() => killFocus();
|
|
|
|
private string lastCommitText = string.Empty;
|
|
|
|
private void killFocus()
|
|
{
|
|
if (GetContainingInputManager()?.FocusedDrawable == this)
|
|
GetContainingFocusManager()?.ChangeFocus(null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits current text on this <see cref="TextBox"/> and releases focus if <see cref="ReleaseFocusOnCommit"/> is set.
|
|
/// </summary>
|
|
protected virtual void Commit()
|
|
{
|
|
FinalizeImeComposition(false);
|
|
|
|
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)
|
|
{
|
|
Scheduler.AddOnce(revertBlockingStateIfRequired);
|
|
base.OnKeyUp(e);
|
|
}
|
|
|
|
protected override bool OnDragStart(DragStartEvent e)
|
|
{
|
|
ignoreOngoingDragSelection = false;
|
|
|
|
if (HasFocus)
|
|
return true;
|
|
|
|
Vector2 posDiff = e.MouseDownPosition - e.MousePosition;
|
|
return Math.Abs(posDiff.X) > Math.Abs(posDiff.Y);
|
|
}
|
|
|
|
protected override void OnDrag(DragEvent e)
|
|
{
|
|
if (ReadOnly)
|
|
return;
|
|
|
|
FinalizeImeComposition(true);
|
|
|
|
if (ignoreOngoingDragSelection)
|
|
return;
|
|
|
|
var lastSelectionBounds = getTextSelectionBounds();
|
|
|
|
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];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (text.Length == 0) return;
|
|
|
|
selectionEnd = getCharacterClosestTo(e.MousePosition);
|
|
if (hasSelection)
|
|
GetContainingFocusManager().AsNonNull().ChangeFocus(this);
|
|
}
|
|
|
|
cursorAndLayout.Invalidate();
|
|
|
|
onTextSelectionChanged(doubleClickWord != null ? TextSelectionType.Word : TextSelectionType.Character, lastSelectionBounds);
|
|
}
|
|
|
|
protected override bool OnDoubleClick(DoubleClickEvent e)
|
|
{
|
|
FinalizeImeComposition(true);
|
|
|
|
var lastSelectionBounds = getTextSelectionBounds();
|
|
|
|
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();
|
|
|
|
onTextSelectionChanged(TextSelectionType.Word, lastSelectionBounds);
|
|
|
|
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 (ReadOnly)
|
|
return true;
|
|
|
|
FinalizeImeComposition(true);
|
|
|
|
var lastSelectionBounds = getTextSelectionBounds();
|
|
|
|
selectionStart = selectionEnd = getCharacterClosestTo(e.MousePosition);
|
|
|
|
cursorAndLayout.Invalidate();
|
|
|
|
onTextDeselected(lastSelectionBounds);
|
|
|
|
return false;
|
|
}
|
|
|
|
protected override void OnMouseUp(MouseUpEvent e)
|
|
{
|
|
doubleClickWord = null;
|
|
}
|
|
|
|
protected override void OnFocusLost(FocusLostEvent e)
|
|
{
|
|
// let's say that a focus loss is not a user event as focus is commonly indirectly lost.
|
|
FinalizeImeComposition(false);
|
|
|
|
unbindInput(e.NextFocused as TextBox);
|
|
|
|
updateCaretVisibility();
|
|
|
|
if (CommitOnFocusLost)
|
|
Commit();
|
|
}
|
|
|
|
public override bool AcceptsFocus => true;
|
|
|
|
protected override bool OnClick(ClickEvent e)
|
|
{
|
|
if (!ReadOnly && textInputBound)
|
|
textInput.EnsureActivated(InputProperties);
|
|
|
|
return !ReadOnly;
|
|
}
|
|
|
|
protected override void OnFocus(FocusEvent e)
|
|
{
|
|
bindInput(e.PreviouslyFocused as TextBox);
|
|
|
|
updateCaretVisibility();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Native TextBox handling (platform-specific)
|
|
|
|
/// <summary>
|
|
/// Whether <see cref="textInput"/> has been activated and bound to.
|
|
/// </summary>
|
|
private bool textInputBound;
|
|
|
|
private void bindInput([CanBeNull] TextBox previous)
|
|
{
|
|
Debug.Assert(textInput != null);
|
|
|
|
if (textInputBound)
|
|
{
|
|
textInput.EnsureActivated(InputProperties);
|
|
return;
|
|
}
|
|
|
|
// TextBox has special handling of text input activation when focus is changed directly from one TextBox to another.
|
|
// We don't deactivate and activate, but instead keep text input active during the focus handoff, so that virtual keyboards on phones don't flicker.
|
|
|
|
if (previous?.textInput == textInput)
|
|
textInput.EnsureActivated(InputProperties, ScreenSpaceDrawQuad.AABBFloat);
|
|
else
|
|
textInput.Activate(InputProperties, ScreenSpaceDrawQuad.AABBFloat);
|
|
|
|
textInput.OnTextInput += handleTextInput;
|
|
textInput.OnImeComposition += handleImeComposition;
|
|
textInput.OnImeResult += handleImeResult;
|
|
|
|
textInputBound = true;
|
|
}
|
|
|
|
private void unbindInput([CanBeNull] TextBox next)
|
|
{
|
|
if (!textInputBound)
|
|
return;
|
|
|
|
textInputBound = false;
|
|
|
|
if (textInput != null)
|
|
{
|
|
// see the comment above, in `bindInput(bool)`.
|
|
if (next?.textInput != textInput)
|
|
textInput.Deactivate();
|
|
|
|
textInput.OnTextInput -= handleTextInput;
|
|
textInput.OnImeComposition -= handleImeComposition;
|
|
textInput.OnImeResult -= handleImeResult;
|
|
}
|
|
|
|
// in case keys are held and we lose focus, we should no longer block key events
|
|
textInputBlocking = false;
|
|
}
|
|
|
|
private void handleTextInput(string text) => textInputScheduler.Add(t =>
|
|
{
|
|
textInputBlocking = true;
|
|
|
|
InsertString(t);
|
|
OnUserTextAdded(t);
|
|
|
|
// clear the flag in the next frame if no buttons are pressed/held.
|
|
// needed in case a text event happens without an associated button press (and release).
|
|
// this could be the case for software keyboards, for instance.
|
|
Scheduler.AddOnce(revertBlockingStateIfRequired);
|
|
}, text);
|
|
|
|
/// <summary>
|
|
/// Reverts the <see cref="textInputBlocking"/> flag to <c>false</c> if no keys are pressed.
|
|
/// </summary>
|
|
private void revertBlockingStateIfRequired() =>
|
|
textInputBlocking &= GetContainingInputManager()?.CurrentState.Keyboard.Keys.HasAnyButtonPressed == true;
|
|
|
|
private void handleImeComposition(string composition, int selectionStart, int selectionLength)
|
|
{
|
|
imeCompositionScheduler.Add(() => onImeComposition(composition, selectionStart, selectionLength, true));
|
|
}
|
|
|
|
private void handleImeResult(string result)
|
|
{
|
|
imeCompositionScheduler.Add(() =>
|
|
{
|
|
onImeComposition(result, result.Length, 0, false);
|
|
onImeResult(true, true);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns how many characters of the two strings match from the beginning, and from the end.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Characters matched from the beginning will not match from the end.
|
|
/// </remarks>
|
|
private void matchBeginningEnd(string a, string b, out int matchBeginning, out int matchEnd)
|
|
{
|
|
int minLength = Math.Min(a.Length, b.Length);
|
|
|
|
matchBeginning = 0;
|
|
|
|
for (int i = 0; i < minLength; i++)
|
|
{
|
|
if (a[i] == b[i])
|
|
matchBeginning = i + 1;
|
|
else
|
|
break;
|
|
}
|
|
|
|
matchEnd = 0;
|
|
|
|
// check how many match (of the ones we didn't match), starting from the end
|
|
for (int i = 1; i <= minLength - matchBeginning; i++)
|
|
{
|
|
if (a[^i] == b[^i])
|
|
matchEnd = i;
|
|
else
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sanitizes the given composition, ensuring it fits within <see cref="LengthLimit"/> and respects <see cref="CanAddCharacter"/>.
|
|
/// </summary>
|
|
/// <returns><c>true</c> if the composition was sanitized in some way.</returns>
|
|
private bool sanitizeComposition(ref string composition, ref int selectionStart, ref int selectionLength)
|
|
{
|
|
bool sanitized = false;
|
|
|
|
// remove characters that can't be added.
|
|
|
|
var builder = new StringBuilder(composition);
|
|
|
|
for (int index = 0; index < builder.Length; index++)
|
|
{
|
|
if (!canAddCharacter(builder[index]))
|
|
{
|
|
builder.Remove(index, 1);
|
|
sanitized = true;
|
|
|
|
if (index < selectionStart)
|
|
{
|
|
selectionStart--;
|
|
}
|
|
else if (index < selectionStart + selectionLength)
|
|
{
|
|
selectionLength--;
|
|
}
|
|
|
|
// move index back so we don't skip over the next character.
|
|
index--;
|
|
}
|
|
}
|
|
|
|
if (sanitized)
|
|
composition = builder.ToString();
|
|
|
|
// trim composition if goes beyond the LengthLimit.
|
|
|
|
int lengthWithoutComposition = text.Length - imeCompositionLength;
|
|
|
|
if (lengthWithoutComposition + composition.Length > LengthLimit)
|
|
{
|
|
composition = composition.Substring(0, (int)LengthLimit - lengthWithoutComposition);
|
|
sanitized = true;
|
|
}
|
|
|
|
// keep selection within bounds.
|
|
// the selection could be out of bounds if it was trimmed by the above,
|
|
// or if the platform-native composition event was ill-formed.
|
|
|
|
if (selectionStart > composition.Length)
|
|
{
|
|
selectionStart = composition.Length;
|
|
sanitized = true;
|
|
}
|
|
|
|
if (selectionStart + selectionLength > composition.Length)
|
|
{
|
|
selectionLength = composition.Length - selectionStart;
|
|
sanitized = true;
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contains all the <see cref="Drawable"/>s from the <see cref="TextFlow"/>
|
|
/// that are part of the current IME composition.
|
|
/// </summary>
|
|
private readonly List<Drawable> imeCompositionDrawables = new List<Drawable>();
|
|
|
|
/// <summary>
|
|
/// Length of the current IME composition.
|
|
/// </summary>
|
|
/// <remarks>A length of <c>0</c> means that IME composition isn't active.</remarks>
|
|
private int imeCompositionLength => imeCompositionDrawables.Count;
|
|
|
|
/// <summary>
|
|
/// Index of the first character in the current composition.
|
|
/// </summary>
|
|
private int imeCompositionStart;
|
|
|
|
/// <remarks>
|
|
/// This checks which parts of the old and new compositions match,
|
|
/// and only updates the non-matching part in the current composition text.
|
|
/// </remarks>
|
|
private void onImeComposition(string newComposition, int newSelectionStart, int newSelectionLength, bool userEvent)
|
|
{
|
|
if (Current.Disabled)
|
|
{
|
|
// don't raise error if composition text is empty, as the empty event could be generated indirectly,
|
|
// and not by explicit user interaction. eg. if IME is reset, input language is changed, etc.
|
|
if (userEvent && !string.IsNullOrEmpty(newComposition))
|
|
{
|
|
NotifyInputError();
|
|
|
|
// importantly, we want to reset the IME so it doesn't falsely report that IME composition is active.
|
|
textInput.ResetIme();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// used for tracking the selection to report for `OnImeComposition()`
|
|
int oldStart = selectionStart;
|
|
int oldEnd = selectionEnd;
|
|
|
|
if (imeCompositionLength == 0)
|
|
{
|
|
// this is the start of a new composition, as we currently have no composition text.
|
|
|
|
imeCompositionStart = selectionLeft;
|
|
|
|
if (string.IsNullOrEmpty(newComposition))
|
|
{
|
|
// we might get an empty composition when the IME is first activated,
|
|
// the IME mode has changed (eg. plaintext -> kana),
|
|
// or when the keyboard layout and language have changed to a one supported by an IME.
|
|
// we can use this opportunity to update the IME window so it appears in
|
|
// the correct place once the user starts compositing.
|
|
updateImeWindowPosition();
|
|
|
|
// early return as SDL might sometimes send empty text editing events.
|
|
// we don't want the currently selected text to be removed in that case
|
|
// (we only want it removed once the user has entered _some_ text).
|
|
// the composition text hasn't changed anyway, so there is no need to go
|
|
// through the rest of the method.
|
|
return;
|
|
}
|
|
|
|
if (hasSelection)
|
|
removeSelection();
|
|
}
|
|
|
|
bool beganChange = beginTextChange();
|
|
|
|
if (sanitizeComposition(ref newComposition, ref newSelectionStart, ref newSelectionLength))
|
|
{
|
|
NotifyInputError();
|
|
}
|
|
|
|
string oldComposition = text.Substring(imeCompositionStart, imeCompositionLength);
|
|
|
|
matchBeginningEnd(oldComposition, newComposition, out int matchBeginning, out int matchEnd);
|
|
|
|
// how many characters have been removed, starting from `matchBeginning`
|
|
int removeCount = oldComposition.Length - matchEnd - matchBeginning;
|
|
|
|
// remove the characters that don't match
|
|
if (removeCount > 0)
|
|
{
|
|
selectionStart = imeCompositionStart + matchBeginning;
|
|
selectionEnd = selectionStart + removeCount;
|
|
removeSelection();
|
|
|
|
imeCompositionDrawables.RemoveRange(matchBeginning, removeCount);
|
|
}
|
|
|
|
// how many characters have been added, starting from `matchBeginning`
|
|
int addCount = newComposition.Length - matchEnd - matchBeginning;
|
|
|
|
if (addCount > 0)
|
|
{
|
|
string addedText = newComposition.Substring(matchBeginning, addCount);
|
|
|
|
// set up selection for `insertString`
|
|
selectionStart = selectionEnd = imeCompositionStart + matchBeginning;
|
|
|
|
int insertPosition = matchBeginning;
|
|
insertString(addedText, d =>
|
|
{
|
|
d.Alpha = 0.6f;
|
|
imeCompositionDrawables.Insert(insertPosition++, d);
|
|
});
|
|
}
|
|
|
|
// update the selection to the one the IME requested.
|
|
// this selection is only a hint to the user, and is not used in the compositing logic.
|
|
selectionStart = imeCompositionStart + newSelectionStart;
|
|
selectionEnd = selectionStart + newSelectionLength;
|
|
|
|
if (userEvent) OnImeComposition(newComposition, removeCount, addCount, oldStart != selectionStart || oldEnd != selectionEnd);
|
|
|
|
endTextChange(beganChange);
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
|
|
private void onImeResult(bool userEvent, bool successful)
|
|
{
|
|
if (Current.Disabled)
|
|
{
|
|
if (userEvent) NotifyInputError();
|
|
// importantly, we don't return here so that we can finalize the composition
|
|
// if we were called because Current was disabled.
|
|
}
|
|
|
|
// we only succeeded if there is pending data in the textbox
|
|
if (imeCompositionDrawables.Count > 0)
|
|
{
|
|
foreach (var d in imeCompositionDrawables)
|
|
{
|
|
d.FadeTo(1, 200, Easing.Out);
|
|
}
|
|
|
|
// move the cursor to end of finalized composition.
|
|
selectionStart = selectionEnd = imeCompositionStart + imeCompositionLength;
|
|
|
|
if (userEvent) OnImeResult(text.Substring(imeCompositionStart, imeCompositionLength), successful);
|
|
}
|
|
|
|
imeCompositionDrawables.Clear();
|
|
|
|
cursorAndLayout.Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the location of the platform-native IME composition window
|
|
/// to the current composition string / current selection.
|
|
/// </summary>
|
|
private void updateImeWindowPosition()
|
|
{
|
|
if (!cursorAndLayout.IsValid || !textInputBound)
|
|
return;
|
|
|
|
int startIndex, endIndex;
|
|
|
|
if (imeCompositionLength > 0)
|
|
{
|
|
startIndex = imeCompositionStart;
|
|
endIndex = imeCompositionStart + imeCompositionLength;
|
|
}
|
|
else
|
|
{
|
|
startIndex = selectionLeft;
|
|
endIndex = selectionRight;
|
|
}
|
|
|
|
float start = getPositionAt(startIndex) - textContainerPosX + LeftRightPadding;
|
|
float end = getPositionAt(endIndex) - textContainerPosX + LeftRightPadding;
|
|
|
|
if (LeftRightPadding <= DrawWidth - LeftRightPadding)
|
|
{
|
|
start = Math.Clamp(start, LeftRightPadding, DrawWidth - LeftRightPadding);
|
|
end = Math.Clamp(end, LeftRightPadding, DrawWidth - LeftRightPadding);
|
|
}
|
|
else
|
|
{
|
|
// DrawWidth is probably zero/invalid, sane fallback instead of throwing in Math.Clamp
|
|
start = 0;
|
|
end = 0;
|
|
}
|
|
|
|
var compositionTextRectangle = new RectangleF
|
|
{
|
|
X = start,
|
|
Y = 0,
|
|
Width = end - start,
|
|
Height = DrawHeight,
|
|
};
|
|
|
|
var quad = ToScreenSpace(compositionTextRectangle);
|
|
textInput.SetImeRectangle(quad.AABBFloat);
|
|
}
|
|
|
|
#endregion
|
|
|
|
public enum TextSelectionType
|
|
{
|
|
/// <summary>
|
|
/// A character was added or removed from the selection.
|
|
/// </summary>
|
|
Character,
|
|
|
|
/// <summary>
|
|
/// A word was added or removed from the selection.
|
|
/// </summary>
|
|
Word,
|
|
|
|
/// <summary>
|
|
/// All of the text was selected (i.e. via <see cref="PlatformAction.SelectAll"/>).
|
|
/// </summary>
|
|
All
|
|
}
|
|
}
|
|
}
|