diff --git a/osu.Game/Graphics/UserInterface/TextBox.cs b/osu.Game/Graphics/UserInterface/TextBox.cs new file mode 100644 index 0000000000..d1fd20b4a7 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TextBox.cs @@ -0,0 +1,704 @@ +using System; +using System.Collections.Generic; +using osu.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Drawables; +using OpenTK.Graphics; +using osu.Framework.Graphics; +using OpenTK; +using osu.Framework.Cached; +using osu.Framework.Graphics.Transformations; +using OpenTK.Input; +using osu.Framework.Input; +using osu.Framework.Graphics.Sprites; +using osu.Framework.MathUtils; + +namespace osu.Game.Graphics.UserInterface +{ + internal class TextBox : MaskingContainer + { + private readonly FlowContainer textFlow; + private Box background; + protected Box cursor; + protected Container TextContainer; + + internal int? LengthLimit; + internal bool ResetTextOnEdit; + internal string textBeforeCommit; + + internal virtual bool AllowClipboardExport => true; + + protected virtual Color4 BackgroundCommit => new Color4(249, 90, 255, 200); + protected virtual Color4 BackgroundFocused => new Color4(100, 100, 100, 255); + protected virtual Color4 BackgroundUnfocused => new Color4(100, 100, 100, 120); + + public bool ReadOnly; + + //BackingTextBox backingTextbox; + + internal delegate void OnCommitHandler(TextBox sender, bool newText); + internal event OnCommitHandler OnCommit; + public event OnCommitHandler OnChange; + + internal float TextSize { get; private set; } + + float length; + + public TextBox(string text, float size, Vector2 pos, float length) + { + TextSize = size; + Position = pos; + this.length = length; + + if (length == 0) + { + length = 1; + SizeMode = InheritMode.X; + } + + Size = new Vector2(length, size); + + Add(background = new Box() + { + Colour = BackgroundUnfocused, + SizeMode = InheritMode.XY, + }); + + Add(TextContainer = new Container() { SizeMode = InheritMode.XY }); + + textFlow = new FlowContainer() + { + Direction = FlowDirection.HorizontalOnly, + //Padding = new Vector2(-TextSize / 3, 0) + }; + + cursor = new Box() + { + Size = Vector2.One, + Colour = Color4.Transparent, + SizeMode = InheritMode.Y, + Alpha = 0 + }; + TextContainer.Add(cursor); + + TextContainer.Add(textFlow); + + Text = text; + } + + private void resetSelection() + { + selectionStart = selectionEnd = text.Length; + cursorAndLayout.Invalidate(); + } + + protected override void Dispose(bool disposing) + { + OnChange = null; + OnCommit = null; + + UnbindTextbox(); + + base.Dispose(disposing); + } + + private float textContainerPosX; + protected virtual float TextContainerIconOffset => 0; + + protected string TextAtLastLayout = string.Empty; + + protected override void UpdateLayout() + { + //have to run this after children flow + cursorAndLayout.Refresh(delegate + { + Vector2 cursorPos = Vector2.Zero; + if (text.Length > 0) + cursorPos.X = getPositionAt(selectionLeft); + + float cursorPosEnd = getPositionAt(selectionEnd); + + float cursorWidth = 1; + + if (selectionLength > 0) + cursorWidth = getPositionAt(selectionRight) - cursorPos.X; + + float cursorRelativePositionInBox = (cursorPosEnd - textContainerPosX) / Width; + + //we only want to reposition the view when the cursor reaches near the extremities. + if (cursorRelativePositionInBox < 0.1 || cursorRelativePositionInBox > 0.9) + { + textContainerPosX = cursorPosEnd - Width / 2; + } + + textContainerPosX = MathHelper.Clamp(textContainerPosX, 0, Math.Max(0, textFlow.Width - Width)); + + TextContainer.MoveToX(TextContainerIconOffset - textContainerPosX, 300, EasingTypes.OutExpo); + + if (HasFocus) + { + cursor.ClearTransformations(); + cursor.MoveTo(cursorPos + new Vector2(2, 0), 60, EasingTypes.Out); + cursor.ScaleTo(new Vector2(cursorWidth, 1), 60, EasingTypes.Out); + + if (selectionLength > 0) + { + cursor.FadeTo(0.5f, 200, EasingTypes.Out); + cursor.FadeColour(new Color4(249, 90, 255, 255), 200, EasingTypes.Out); + } + else + { + cursor.FadeTo(0.5f, 200, EasingTypes.Out); + cursor.FadeColour(Color4.White, 200, EasingTypes.Out); + cursor.Transformations.Add(new Transformation(TransformationType.Fade, 0.5f, 0.2f, Time + 200, Time + 200 + /*(OsuGame.Audio.BeatSyncing ? (int)OsuGame.Audio.BeatLength : */500) + { + Easing = EasingTypes.InOutSine, + Loop = true + }); + } + } + + OnChange?.Invoke(this, TextAtLastLayout != text); + TextAtLastLayout = text; + + return cursorPos; + }); + } + + private float getPositionAt(int index) + { + if (index > 0) + { + if (index < text.Length) + return textFlow.Children[index].Position.X + textFlow.Position.X; + else + return textFlow.Children[index - 1].Position.X + textFlow.Children[index - 1].Size.X + textFlow.Padding.X + textFlow.Position.X; + } + else + return 0; + } + + private int getCharacterClosestTo(Vector2 pos) + { + pos = textFlow.GetLocalPosition(pos); + + int i = 0; + foreach (Drawable d in textFlow.Children) + { + if (d.Position.X + d.Size.X / 2 > pos.X) + break; + i++; + } + + return i; + } + + internal bool HandleLeftRightArrows = true; + + int selectionStart; + int selectionEnd; + + int selectionLength => Math.Abs(selectionEnd - selectionStart); + + int selectionLeft => Math.Min(selectionStart, selectionEnd); + int selectionRight => Math.Max(selectionStart, selectionEnd); + + Cached cursorAndLayout = new Cached(); + + private void moveSelection(int offset, bool expand) + { + //if (backingTextbox?.ImeActive == true) return; + + int oldStart = selectionStart; + int oldEnd = selectionEnd; + + if (expand) + selectionEnd = MathHelper.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 = MathHelper.Clamp((offset > 0 ? selectionRight : selectionLeft) + offset, 0, text.Length); + } + + if (oldStart != selectionStart || oldEnd != selectionEnd) + { + Game.Audio.Sample.GetSample(@"key-movement"); + cursorAndLayout.Invalidate(); + } + } + + private bool removeCharacterOrSelection(bool sound = true) + { + if (text.Length == 0) return false; + if (selectionLength == 0 && selectionLeft == 0) return false; + + int count = MathHelper.Clamp(selectionLength, 1, text.Length); + int start = MathHelper.Clamp(selectionLength > 0 ? selectionLeft : selectionLeft - 1, 0, text.Length - count); + + if (count == 0) return false; + + if (sound) + Game.Audio.Sample.GetSample(@"key-delete"); + + for (int i = 0; i < count; i++) + { + Drawable d = textFlow.Children[start]; + textFlow.Remove(d); + + TextContainer.Add(d); + d.FadeOut(200); + d.MoveToY(d.Size.Y, 200, EasingTypes.InExpo); + d.Expire(); + } + text = text.Remove(start, count); + + if (selectionLength > 0) + selectionStart = selectionEnd = selectionLeft; + else + selectionStart = selectionEnd = selectionLeft - 1; + + cursorAndLayout.Invalidate(); + return true; + } + + protected virtual Drawable AddCharacterToFlow(char c) + { + for (int i = selectionLeft; i < text.Length; i++) + textFlow.Children[i].Depth = i + 1; + + Drawable ch; + + if (char.IsWhiteSpace(c)) + { + float width = TextSize / 2; + + switch ((int)c) + { + case 0x3000: //double-width space + width = TextSize; + break; + } + + textFlow.Add(ch = new Container() + { + SizeMode = InheritMode.None, + Size = new Vector2(width, TextSize), + Depth = selectionLeft + }); + } + else + { + textFlow.Add(ch = new SpriteText() + { + Text = c.ToString(), + Depth = selectionLeft + }); + } + + return ch; + } + + /// + /// Insert an arbitrary string into the text at the current position. + /// + /// + private void insertString(string text) + { + foreach (char c in text) + { + if (char.IsControl(c)) continue; + addCharacter(c); + } + } + + private Drawable addCharacter(char c) + { + if (selectionLength > 0) + removeCharacterOrSelection(); + + if (text.Length + 1 > LengthLimit) + { + if (background.Alpha > 0) + background.FlashColour(Color4.Red, 200); + else + textFlow.FlashColour(Color4.Red, 200); + return null; + } + + Drawable ch = AddCharacterToFlow(c); + + ch.Position = new Vector2(0, TextSize); + ch.MoveToY(0, 200, EasingTypes.OutExpo); + + text = text.Insert(selectionLeft, c.ToString()); + selectionStart = selectionEnd = selectionLeft + 1; + + cursorAndLayout.Invalidate(); + + return ch; + } + + private string text; + internal virtual string Text + { + get + { + return text; + } + set + { + if (value == text) + return; + + int startBefore = selectionStart; + selectionStart = selectionEnd = 0; + textFlow.Clear(); + text = string.Empty; + + foreach (char c in value) + addCharacter(c); + + selectionStart = MathHelper.Clamp(startBefore, 0, text.Length); + + cursorAndLayout.Invalidate(); + } + } + + public string SelectedText => selectionLength > 0 ? Text.Substring(selectionLeft, selectionLength) : string.Empty; + + protected override bool OnKeyDown(InputState state, KeyDownEventArgs args) + { + //if (!HasFocus) + // return false; + + //if (backingTextbox?.ImeActive == true) return true; + + switch (args.Key) + { + case Key.Tab: + return false; + case Key.End: + moveSelection(text.Length, state.Keyboard.ShiftPressed); + return true; + case Key.Home: + moveSelection(-text.Length, state.Keyboard.ShiftPressed); + return true; + case Key.Left: + { + if (!HandleLeftRightArrows) return false; + + if (selectionEnd == 0) return true; + + int amount = 1; + if (state.Keyboard.ControlPressed) + { + int lastSpace = text.LastIndexOf(' ', Math.Max(0, selectionEnd - 2)); + if (lastSpace >= 0) + amount = selectionEnd - lastSpace - 1; + else + amount = selectionEnd; + } + + moveSelection(-amount, state.Keyboard.ShiftPressed); + } + return true; + case Key.Right: + { + if (!HandleLeftRightArrows) return false; + + if (selectionEnd == text.Length) return true; + + int amount = 1; + if (state.Keyboard.ControlPressed) + { + int nextSpace = text.IndexOf(' ', selectionEnd + 1); + if (nextSpace >= 0) + amount = nextSpace - selectionEnd; + else + amount = text.Length - selectionEnd; + } + + moveSelection(amount, state.Keyboard.ShiftPressed); + } + + return true; + case Key.Enter: + TriggerFocusLost(state); + return true; + case Key.Delete: + if (selectionLength == 0) + { + if (text.Length == selectionStart) + return true; + + if (state.Keyboard.ControlPressed) + { + int spacePos = selectionStart; + while (text[spacePos] == ' ' && spacePos < text.Length) + spacePos++; + + spacePos = MathHelper.Clamp(text.IndexOf(' ', spacePos), 0, text.Length); + selectionEnd = spacePos; + + if (selectionStart == 0 && spacePos == 0) + selectionEnd = text.Length; + + if (selectionLength == 0) + return true; + } + else + { + //we're deleting in front of the cursor, so move the cursor forward once first + selectionStart = selectionEnd = selectionStart + 1; + } + } + + removeCharacterOrSelection(); + return true; + case Key.Back: + if (selectionLength == 0 && state.Keyboard.ControlPressed) + { + int spacePos = selectionLeft >= 2 ? Math.Max(0, text.LastIndexOf(' ', selectionLeft - 2) + 1) : 0; + selectionStart = spacePos; + } + + removeCharacterOrSelection(); + return true; + } + + if (state.Keyboard.ControlPressed) + { + //handling of function keys + switch (args.Key) + { + case Key.A: + selectionStart = 0; + selectionEnd = text.Length; + cursorAndLayout.Invalidate(); + return true; + case Key.C: + if (string.IsNullOrEmpty(SelectedText) || !AllowClipboardExport) return true; + //System.Windows.Forms.Clipboard.SetText(SelectedText); + return true; + case Key.X: + if (string.IsNullOrEmpty(SelectedText)) return true; + + //if (AllowClipboardExport) + // System.Windows.Forms.Clipboard.SetText(SelectedText); + removeCharacterOrSelection(); + return true; + case Key.V: + //the text is pasted into the hidden textbox, so we don't need any direct clipboard interaction here. + //insertString(backingTextbox.GetPendingText()); + return true; + } + + return false; + } + + string str = "A";// backingTextbox.GetPendingText(); + if (!string.IsNullOrEmpty(str)) + { + if (state.Keyboard.ShiftPressed) + Game.Audio.Sample.GetSample(@"key-caps"); + else + Game.Audio.Sample.GetSample($@"key-press-{RNG.Next(1, 5)}"); + insertString(str); + + return true; + } + + return false; + } + + protected override bool OnDrag(InputState state) + { + //if (backingTextbox?.ImeActive == true) return true; + + if (text.Length == 0) return true; + + selectionEnd = getCharacterClosestTo(state.Mouse.Position); + if (selectionLength > 0) + TriggerFocus(); + + cursorAndLayout.Invalidate(); + return true; + } + + protected override bool OnDragStart(InputState state) + { + //need to handle this so we get onDrag events. + return true; + } + + protected override bool OnDoubleClick(InputState state) + { + //if (backingTextbox?.ImeActive == true) return true; + + if (text.Length == 0) return true; + + int hover = Math.Min(text.Length - 1, getCharacterClosestTo(state.Mouse.Position)); + + int lastSpace = text.LastIndexOf(' ', hover); + int nextSpace = text.IndexOf(' ', hover); + + selectionStart = lastSpace >= 0 ? lastSpace + 1 : 0; + selectionEnd = nextSpace >= 0 ? nextSpace : text.Length; + cursorAndLayout.Invalidate(); + return true; + } + + protected override bool OnMouseDown(InputState state, MouseDownEventArgs args) + { + //if (backingTextbox?.ImeActive == true) return true; + + selectionStart = selectionEnd = getCharacterClosestTo(state.Mouse.Position); + + cursorAndLayout.Invalidate(); + + return true; + } + + protected override void OnFocusLost(InputState state) + { + UnbindTextbox(); + + cursor.ClearTransformations(); + cursor.FadeOut(200); + + if (state.Keyboard.Keys.Contains(Key.Enter)) + { + background.Colour = BackgroundUnfocused; + background.ClearTransformations(); + background.FlashColour(BackgroundCommit, 400); + + Game.Audio.Sample.GetSample(@"key-confirm"); + OnCommit?.Invoke(this, true); + } + else + { + background.ClearTransformations(); + background.FadeColour(BackgroundUnfocused, 200, EasingTypes.OutExpo); + } + + cursorAndLayout.Invalidate(); + } + + protected override bool OnFocus(InputState state) + { + if (ReadOnly) return false; + + //BindTextbox(); + + textBeforeCommit = Text; + if (ResetTextOnEdit) + Text = string.Empty; + + background.ClearTransformations(); + background.FadeColour(BackgroundFocused, 200, EasingTypes.Out); + + cursorAndLayout.Invalidate(); + return true; + } + + #region Native TextBox handling (winform specific) + protected void UnbindTextbox() + { + //backingTextbox?.Deactivate(OsuGame.Window.Form); + } + + //protected void BindTextbox() + //{ + // if (backingTextbox == null) + // { + // backingTextbox = new BackingTextBox(); + // backingTextbox.OnNewImeComposition += onImeComposition; + // backingTextbox.OnNewImeResult += onImeResult; + // } + + // backingTextbox.Activate(OsuGame.Window.Form); + //} + + //private void onImeResult(string s) + //{ + // //we only succeeded if there is pending data in the textbox + // if (imeDrawables.Count > 0) + // { + // Game.Audio.Sample.GetSample($@"key-confirm"); + + // foreach (Drawable d in imeDrawables) + // { + // d.Colour = Color4.White; + // d.FadeTo(1, 200, EasingTypes.Out); + // } + // } + + // imeDrawables.Clear(); + //} + + List imeDrawables = new List(); + + private void onImeComposition(string s) + { + //search for unchanged characters.. + int matchCount = 0; + bool matching = true; + bool didDelete = false; + + int searchStart = text.Length - imeDrawables.Count; + + //we want to keep processing to the end of the longest string (the current displayed or the new composition). + int maxLength = Math.Max(imeDrawables.Count, s.Length); + + for (int i = 0; i < maxLength; i++) + { + if (matching && searchStart + i < text.Length && i < s.Length && text[searchStart + i] == s[i]) + { + matchCount = i + 1; + continue; + } + + matching = false; + + if (matchCount < imeDrawables.Count) + { + //if we are no longer matching, we want to remove all further characters. + removeCharacterOrSelection(false); + imeDrawables.RemoveAt(matchCount); + didDelete = true; + } + } + + if (matchCount == s.Length) + { + //in the case of backspacing (or a NOP), we can exit early here. + if (didDelete) + Game.Audio.Sample.GetSample($@"key-delete").Play(); + return; + } + + //add any new or changed characters + for (int i = matchCount; i < s.Length; i++) + { + Drawable dr = addCharacter(s[i]); + if (dr != null) + { + dr.Colour = Color4.Aqua; + dr.Alpha = 0.6f; + imeDrawables.Add(dr); + } + } + + Game.Audio.Sample.GetSample($@"key-press-{RNG.Next(1, 5)}"); + } + #endregion + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 0ce2220ef7..1bf9f9d655 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -57,6 +57,7 @@ +