A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using System.Diagnostics;
7using System.Linq;
8using osu.Framework.Caching;
9using osu.Framework.Graphics.Containers;
10using osu.Framework.Graphics.Sprites;
11using osu.Framework.Input;
12using osu.Framework.Threading;
13using osuTK;
14using osuTK.Graphics;
15using osuTK.Input;
16using osu.Framework.Allocation;
17using osu.Framework.Bindables;
18using osu.Framework.Development;
19using osu.Framework.Platform;
20using osu.Framework.Input.Bindings;
21using osu.Framework.Input.Events;
22using osu.Framework.Timing;
23using osu.Framework.Localisation;
24
25namespace osu.Framework.Graphics.UserInterface
26{
27 public abstract class TextBox : TabbableContainer, IHasCurrentValue<string>, IKeyBindingHandler<PlatformAction>
28 {
29 protected FillFlowContainer TextFlow { get; private set; }
30 protected Container TextContainer { get; private set; }
31
32 public override bool HandleNonPositionalInput => HasFocus;
33
34 /// <summary>
35 /// Padding to be used within the TextContainer. Requires special handling due to the sideways scrolling of text content.
36 /// </summary>
37 protected virtual float LeftRightPadding => 5;
38
39 public int? LengthLimit;
40
41 /// <summary>
42 /// Whether clipboard copying functionality is allowed.
43 /// </summary>
44 protected virtual bool AllowClipboardExport => true;
45
46 /// <summary>
47 /// Whether seeking to word boundaries is allowed.
48 /// </summary>
49 protected virtual bool AllowWordNavigation => true;
50
51 //represents the left/right selection coordinates of the word double clicked on when dragging
52 private int[] doubleClickWord;
53
54 /// <summary>
55 /// Whether this TextBox should accept left and right arrow keys for navigation.
56 /// </summary>
57 public virtual bool HandleLeftRightArrows => true;
58
59 /// <summary>
60 /// Check if a character can be added to this TextBox.
61 /// </summary>
62 /// <param name="character">The pending character.</param>
63 /// <returns>Whether the character is allowed to be added.</returns>
64 protected virtual bool CanAddCharacter(char character) => true;
65
66 private bool readOnly;
67
68 public bool ReadOnly
69 {
70 get => readOnly;
71 set
72 {
73 readOnly = value;
74
75 if (readOnly)
76 KillFocus();
77 }
78 }
79
80 /// <summary>
81 /// Whether the textbox should rescind focus on commit.
82 /// </summary>
83 public bool ReleaseFocusOnCommit { get; set; } = true;
84
85 /// <summary>
86 /// Whether a commit should be triggered whenever the textbox loses focus.
87 /// </summary>
88 public bool CommitOnFocusLost { get; set; }
89
90 public override bool CanBeTabbedTo => !ReadOnly;
91
92 private ITextInputSource textInput;
93
94 private Clipboard clipboard;
95
96 private readonly Caret caret;
97
98 public delegate void OnCommitHandler(TextBox sender, bool newText);
99
100 /// <summary>
101 /// Fired whenever text is committed via a user action.
102 /// This usually happens on pressing enter, but can also be triggered on focus loss automatically, via <see cref="CommitOnFocusLost"/>.
103 /// </summary>
104 public event OnCommitHandler OnCommit;
105
106 private readonly Scheduler textUpdateScheduler = new Scheduler(() => ThreadSafety.IsUpdateThread, null);
107
108 protected TextBox()
109 {
110 Masking = true;
111
112 Children = new Drawable[]
113 {
114 TextContainer = new Container
115 {
116 AutoSizeAxes = Axes.X,
117 RelativeSizeAxes = Axes.Y,
118 Anchor = Anchor.CentreLeft,
119 Origin = Anchor.CentreLeft,
120 Position = new Vector2(LeftRightPadding, 0),
121 Children = new Drawable[]
122 {
123 Placeholder = CreatePlaceholder(),
124 caret = CreateCaret(),
125 TextFlow = new FillFlowContainer
126 {
127 Anchor = Anchor.CentreLeft,
128 Origin = Anchor.CentreLeft,
129 Direction = FillDirection.Horizontal,
130 AutoSizeAxes = Axes.X,
131 RelativeSizeAxes = Axes.Y,
132 },
133 },
134 },
135 };
136
137 Current.ValueChanged += e => { Text = e.NewValue; };
138 caret.Hide();
139 }
140
141 [BackgroundDependencyLoader]
142 private void load(GameHost host)
143 {
144 textInput = host.GetTextInput();
145 clipboard = host.GetClipboard();
146
147 if (textInput != null)
148 {
149 textInput.OnNewImeComposition += s =>
150 {
151 textUpdateScheduler.Add(() => onImeComposition(s));
152 cursorAndLayout.Invalidate();
153 };
154 textInput.OnNewImeResult += s =>
155 {
156 textUpdateScheduler.Add(onImeResult);
157 cursorAndLayout.Invalidate();
158 };
159 }
160 }
161
162 public virtual bool OnPressed(PlatformAction action)
163 {
164 if (!HasFocus)
165 return false;
166
167 if (!HandleLeftRightArrows && (action == PlatformAction.MoveBackwardChar || action == PlatformAction.MoveForwardChar))
168 return false;
169
170 switch (action)
171 {
172 // Clipboard
173 case PlatformAction.Cut:
174 case PlatformAction.Copy:
175 if (string.IsNullOrEmpty(SelectedText) || !AllowClipboardExport) return true;
176
177 clipboard?.SetText(SelectedText);
178
179 if (action == PlatformAction.Cut)
180 DeleteBy(0);
181
182 return true;
183
184 case PlatformAction.Paste:
185 //the text may get pasted into the hidden textbox, so we don't need any direct clipboard interaction here.
186 string pending = textInput?.GetPendingText();
187
188 if (string.IsNullOrEmpty(pending))
189 pending = clipboard?.GetText();
190
191 InsertString(pending);
192 return true;
193
194 case PlatformAction.SelectAll:
195 selectionStart = 0;
196 selectionEnd = text.Length;
197 cursorAndLayout.Invalidate();
198 return true;
199
200 // Cursor Manipulation
201 case PlatformAction.MoveBackwardChar:
202 MoveCursorBy(-1);
203 return true;
204
205 case PlatformAction.MoveForwardChar:
206 MoveCursorBy(1);
207 return true;
208
209 case PlatformAction.MoveBackwardWord:
210 MoveCursorBy(GetBackwardWordAmount());
211 return true;
212
213 case PlatformAction.MoveForwardWord:
214 MoveCursorBy(GetForwardWordAmount());
215 return true;
216
217 case PlatformAction.MoveBackwardLine:
218 MoveCursorBy(GetBackwardLineAmount());
219 return true;
220
221 case PlatformAction.MoveForwardLine:
222 MoveCursorBy(GetForwardLineAmount());
223 return true;
224
225 // Deletion
226 case PlatformAction.DeleteBackwardChar:
227 DeleteBy(-1);
228 return true;
229
230 case PlatformAction.DeleteForwardChar:
231 DeleteBy(1);
232 return true;
233
234 case PlatformAction.DeleteBackwardWord:
235 DeleteBy(GetBackwardWordAmount());
236 return true;
237
238 case PlatformAction.DeleteForwardWord:
239 DeleteBy(GetForwardWordAmount());
240 return true;
241
242 case PlatformAction.DeleteBackwardLine:
243 DeleteBy(GetBackwardLineAmount());
244 return true;
245
246 case PlatformAction.DeleteForwardLine:
247 DeleteBy(GetForwardLineAmount());
248 return true;
249
250 // Expand selection
251 case PlatformAction.SelectBackwardChar:
252 ExpandSelectionBy(-1);
253 return true;
254
255 case PlatformAction.SelectForwardChar:
256 ExpandSelectionBy(1);
257 return true;
258
259 case PlatformAction.SelectBackwardWord:
260 ExpandSelectionBy(GetBackwardWordAmount());
261 return true;
262
263 case PlatformAction.SelectForwardWord:
264 ExpandSelectionBy(GetForwardWordAmount());
265 return true;
266
267 case PlatformAction.SelectBackwardLine:
268 ExpandSelectionBy(GetBackwardLineAmount());
269 return true;
270
271 case PlatformAction.SelectForwardLine:
272 ExpandSelectionBy(GetForwardLineAmount());
273 return true;
274 }
275
276 return false;
277 }
278
279 public virtual void OnReleased(PlatformAction action)
280 {
281 }
282
283 /// <summary>
284 /// Find the word boundary in the backward direction, then return the negative amount of characters.
285 /// </summary>
286 protected int GetBackwardWordAmount()
287 {
288 if (!AllowWordNavigation)
289 return -1;
290
291 int searchPrev = Math.Clamp(selectionEnd - 1, 0, Math.Max(0, Text.Length - 1));
292 while (searchPrev > 0 && text[searchPrev] == ' ')
293 searchPrev--;
294 int lastSpace = text.LastIndexOf(' ', searchPrev);
295 return lastSpace > 0 ? -(selectionEnd - lastSpace - 1) : -selectionEnd;
296 }
297
298 /// <summary>
299 /// Find the word boundary in the forward direction, then return the positive amount of characters.
300 /// </summary>
301 protected int GetForwardWordAmount()
302 {
303 if (!AllowWordNavigation)
304 return 1;
305
306 int searchNext = Math.Clamp(selectionEnd, 0, Math.Max(0, Text.Length - 1));
307 while (searchNext < Text.Length && text[searchNext] == ' ')
308 searchNext++;
309 int nextSpace = text.IndexOf(' ', searchNext);
310 return (nextSpace >= 0 ? nextSpace : text.Length) - selectionEnd;
311 }
312
313 // Currently only single line is supported and line length and text length are the same.
314 protected int GetBackwardLineAmount() => -text.Length;
315
316 protected int GetForwardLineAmount() => text.Length;
317
318 /// <summary>
319 /// Move the current cursor by the signed <paramref name="amount"/>.
320 /// </summary>
321 protected void MoveCursorBy(int amount)
322 {
323 selectionStart = selectionEnd;
324 cursorAndLayout.Invalidate();
325 moveSelection(amount, false);
326 }
327
328 /// <summary>
329 /// Expand the current selection by the signed <paramref name="amount"/>.
330 /// </summary>
331 protected void ExpandSelectionBy(int amount)
332 {
333 moveSelection(amount, true);
334 }
335
336 /// <summary>
337 /// If there is a selection, delete the selected text.
338 /// Otherwise, delete characters from the cursor position by the signed <paramref name="amount"/>.
339 /// A negative amount represents a backward deletion, and a positive amount represents a forward deletion.
340 /// </summary>
341 protected void DeleteBy(int amount)
342 {
343 if (selectionLength == 0)
344 selectionEnd = Math.Clamp(selectionStart + amount, 0, text.Length);
345
346 if (selectionLength > 0)
347 {
348 string removedText = removeSelection();
349 OnUserTextRemoved(removedText);
350 }
351 }
352
353 internal override void UpdateClock(IFrameBasedClock clock)
354 {
355 base.UpdateClock(clock);
356 textUpdateScheduler.UpdateClock(Clock);
357 }
358
359 protected override void Dispose(bool isDisposing)
360 {
361 OnCommit = null;
362
363 unbindInput();
364
365 base.Dispose(isDisposing);
366 }
367
368 private float textContainerPosX;
369
370 private string textAtLastLayout = string.Empty;
371
372 private void updateCursorAndLayout()
373 {
374 Placeholder.Font = Placeholder.Font.With(size: CalculatedTextSize);
375
376 textUpdateScheduler.Update();
377
378 float cursorPos = 0;
379 if (text.Length > 0)
380 cursorPos = getPositionAt(selectionLeft);
381
382 float cursorPosEnd = getPositionAt(selectionEnd);
383
384 float? selectionWidth = null;
385 if (selectionLength > 0)
386 selectionWidth = getPositionAt(selectionRight) - cursorPos;
387
388 float cursorRelativePositionAxesInBox = (cursorPosEnd - textContainerPosX) / DrawWidth;
389
390 //we only want to reposition the view when the cursor reaches near the extremities.
391 if (cursorRelativePositionAxesInBox < 0.1 || cursorRelativePositionAxesInBox > 0.9)
392 {
393 textContainerPosX = cursorPosEnd - DrawWidth / 2 + LeftRightPadding * 2;
394 }
395
396 textContainerPosX = Math.Clamp(textContainerPosX, 0, Math.Max(0, TextFlow.DrawWidth - DrawWidth + LeftRightPadding * 2));
397
398 TextContainer.MoveToX(LeftRightPadding - textContainerPosX, 300, Easing.OutExpo);
399
400 if (HasFocus)
401 caret.DisplayAt(new Vector2(cursorPos, 0), selectionWidth);
402
403 if (textAtLastLayout != text)
404 Current.Value = text;
405
406 if (textAtLastLayout.Length == 0 || text.Length == 0)
407 {
408 if (text.Length == 0)
409 Placeholder.Show();
410 else
411 Placeholder.Hide();
412 }
413
414 textAtLastLayout = text;
415 }
416
417 protected override void UpdateAfterChildren()
418 {
419 base.UpdateAfterChildren();
420
421 //have to run this after children flow
422 if (!cursorAndLayout.IsValid)
423 {
424 updateCursorAndLayout();
425 cursorAndLayout.Validate();
426 }
427 }
428
429 private float getPositionAt(int index)
430 {
431 if (index > 0)
432 {
433 if (index < text.Length)
434 return TextFlow.Children[index].DrawPosition.X + TextFlow.DrawPosition.X;
435
436 var d = TextFlow.Children[index - 1];
437 return d.DrawPosition.X + d.DrawSize.X + TextFlow.Spacing.X + TextFlow.DrawPosition.X;
438 }
439
440 return 0;
441 }
442
443 private int getCharacterClosestTo(Vector2 pos)
444 {
445 pos = Parent.ToSpaceOfOtherDrawable(pos, TextFlow);
446
447 int i = 0;
448
449 foreach (Drawable d in TextFlow.Children)
450 {
451 if (d.DrawPosition.X + d.DrawSize.X / 2 > pos.X)
452 break;
453
454 i++;
455 }
456
457 return i;
458 }
459
460 private int selectionStart;
461 private int selectionEnd;
462
463 private int selectionLength => Math.Abs(selectionEnd - selectionStart);
464
465 private int selectionLeft => Math.Min(selectionStart, selectionEnd);
466 private int selectionRight => Math.Max(selectionStart, selectionEnd);
467
468 private readonly Cached cursorAndLayout = new Cached();
469
470 private void moveSelection(int offset, bool expand)
471 {
472 if (textInput?.ImeActive == true) return;
473
474 int oldStart = selectionStart;
475 int oldEnd = selectionEnd;
476
477 if (expand)
478 selectionEnd = Math.Clamp(selectionEnd + offset, 0, text.Length);
479 else
480 {
481 if (selectionLength > 0 && Math.Abs(offset) <= 1)
482 {
483 //we don't want to move the location when "removing" an existing selection, just set the new location.
484 if (offset > 0)
485 selectionEnd = selectionStart = selectionRight;
486 else
487 selectionEnd = selectionStart = selectionLeft;
488 }
489 else
490 selectionEnd = selectionStart = Math.Clamp((offset > 0 ? selectionRight : selectionLeft) + offset, 0, text.Length);
491 }
492
493 if (oldStart != selectionStart || oldEnd != selectionEnd)
494 {
495 OnCaretMoved(expand);
496 cursorAndLayout.Invalidate();
497 }
498 }
499
500 /// <summary>
501 /// Removes the selected text if a selection persists.
502 /// </summary>
503 private string removeSelection() => removeCharacters(selectionLength);
504
505 /// <summary>
506 /// Removes a specified <paramref name="number"/> of characters left side of the current position.
507 /// </summary>
508 /// <remarks>
509 /// If a selection persists, <see cref="removeSelection"/> must be called instead.
510 /// </remarks>
511 /// <returns>A string of the removed characters.</returns>
512 private string removeCharacters(int number = 1)
513 {
514 if (Current.Disabled || text.Length == 0)
515 return string.Empty;
516
517 int removeStart = Math.Clamp(selectionRight - number, 0, selectionRight);
518 int removeCount = selectionRight - removeStart;
519
520 if (removeCount == 0)
521 return string.Empty;
522
523 Debug.Assert(selectionLength == 0 || removeCount == selectionLength);
524
525 foreach (var d in TextFlow.Children.Skip(removeStart).Take(removeCount).ToArray()) //ToArray since we are removing items from the children in this block.
526 {
527 TextFlow.Remove(d);
528
529 TextContainer.Add(d);
530
531 // account for potentially altered height of textbox
532 d.Y = TextFlow.BoundingBox.Y;
533
534 d.Hide();
535 d.Expire();
536 }
537
538 var removedText = text.Substring(removeStart, removeCount);
539 text = text.Remove(removeStart, removeCount);
540
541 // Reorder characters depth after removal to avoid ordering issues with newly added characters.
542 for (int i = removeStart; i < TextFlow.Count; i++)
543 TextFlow.ChangeChildDepth(TextFlow[i], getDepthForCharacterIndex(i));
544
545 selectionStart = selectionEnd = removeStart;
546
547 cursorAndLayout.Invalidate();
548
549 return removedText;
550 }
551
552 /// <summary>
553 /// Creates a single character. Override <see cref="Drawable.Show"/> and <see cref="Drawable.Hide"/> for custom behavior.
554 /// </summary>
555 /// <param name="c">The character that this <see cref="Drawable"/> should represent.</param>
556 /// <returns>A <see cref="Drawable"/> that represents the character <paramref name="c"/> </returns>
557 protected virtual Drawable GetDrawableCharacter(char c) => new SpriteText { Text = c.ToString(), Font = new FontUsage(size: CalculatedTextSize) };
558
559 protected virtual Drawable AddCharacterToFlow(char c)
560 {
561 // Remove all characters to the right and store them in a local list,
562 // such that their depth can be updated.
563 List<Drawable> charsRight = new List<Drawable>();
564 foreach (Drawable d in TextFlow.Children.Skip(selectionLeft))
565 charsRight.Add(d);
566 TextFlow.RemoveRange(charsRight);
567
568 // Update their depth to make room for the to-be inserted character.
569 int i = selectionLeft;
570 foreach (Drawable d in charsRight)
571 d.Depth = getDepthForCharacterIndex(i++);
572
573 // Add the character
574 Drawable ch = GetDrawableCharacter(c);
575 ch.Depth = getDepthForCharacterIndex(selectionLeft);
576
577 TextFlow.Add(ch);
578
579 // Add back all the previously removed characters
580 TextFlow.AddRange(charsRight);
581
582 return ch;
583 }
584
585 private float getDepthForCharacterIndex(int index) => -index;
586
587 protected float CalculatedTextSize => TextFlow.DrawSize.Y - (TextFlow.Padding.Top + TextFlow.Padding.Bottom);
588
589 protected void InsertString(string value) => insertString(value);
590
591 private void insertString(string value, Action<Drawable> drawableCreationParameters = null)
592 {
593 if (string.IsNullOrEmpty(value)) return;
594
595 if (Current.Disabled)
596 {
597 NotifyInputError();
598 return;
599 }
600
601 foreach (char c in value)
602 {
603 if (char.IsControl(c) || !CanAddCharacter(c))
604 {
605 NotifyInputError();
606 continue;
607 }
608
609 if (selectionLength > 0)
610 removeSelection();
611
612 if (text.Length + 1 > LengthLimit)
613 {
614 NotifyInputError();
615 break;
616 }
617
618 Drawable drawable = AddCharacterToFlow(c);
619
620 drawable.Show();
621 drawableCreationParameters?.Invoke(drawable);
622
623 text = text.Insert(selectionLeft, c.ToString());
624
625 selectionStart = selectionEnd = selectionLeft + 1;
626
627 cursorAndLayout.Invalidate();
628 }
629 }
630
631 /// <summary>
632 /// Called whenever an invalid character has been entered
633 /// </summary>
634 protected abstract void NotifyInputError();
635
636 /// <summary>
637 /// Invoked when new text is added via user input.
638 /// </summary>
639 /// <param name="added">The text which was added.</param>
640 protected virtual void OnUserTextAdded(string added)
641 {
642 }
643
644 /// <summary>
645 /// Invoked when text is removed via user input.
646 /// </summary>
647 /// <param name="removed">The text which was removed.</param>
648 protected virtual void OnUserTextRemoved(string removed)
649 {
650 }
651
652 /// <summary>
653 /// Invoked whenever a text string has been committed to the textbox.
654 /// </summary>
655 /// <param name="textChanged">Whether the current text string is different than the last committed.</param>
656 protected virtual void OnTextCommitted(bool textChanged)
657 {
658 }
659
660 /// <summary>
661 /// Invoked whenever the caret has moved from its position.
662 /// </summary>
663 /// <param name="selecting">Whether the caret is selecting text while moving.</param>
664 protected virtual void OnCaretMoved(bool selecting)
665 {
666 }
667
668 /// <summary>
669 /// Creates a placeholder that shows whenever the textbox is empty. Override <see cref="Drawable.Show"/> or <see cref="Drawable.Hide"/> for custom behavior.
670 /// </summary>
671 /// <returns>The placeholder</returns>
672 protected abstract SpriteText CreatePlaceholder();
673
674 protected SpriteText Placeholder;
675
676 public LocalisableString PlaceholderText
677 {
678 get => Placeholder.Text;
679 set => Placeholder.Text = value;
680 }
681
682 protected abstract Caret CreateCaret();
683
684 private readonly BindableWithCurrent<string> current = new BindableWithCurrent<string>();
685
686 public Bindable<string> Current
687 {
688 get => current.Current;
689 set => current.Current = value;
690 }
691
692 private string text = string.Empty;
693
694 public virtual string Text
695 {
696 get => text;
697 set
698 {
699 if (Current.Disabled)
700 return;
701
702 if (value == text)
703 return;
704
705 lastCommitText = value ??= string.Empty;
706
707 if (value.Length == 0)
708 Placeholder.Show();
709 else
710 Placeholder.Hide();
711
712 if (!IsLoaded)
713 Current.Value = text = value;
714
715 textUpdateScheduler.Add(delegate
716 {
717 int startBefore = selectionStart;
718 selectionStart = selectionEnd = 0;
719 TextFlow?.Clear();
720
721 text = string.Empty;
722 InsertString(value);
723
724 selectionStart = Math.Clamp(startBefore, 0, text.Length);
725 });
726
727 cursorAndLayout.Invalidate();
728 }
729 }
730
731 public string SelectedText => selectionLength > 0 ? Text.Substring(selectionLeft, selectionLength) : string.Empty;
732
733 private bool consumingText;
734
735 /// <summary>
736 /// Begin consuming text from an <see cref="ITextInputSource"/>.
737 /// Continues to consume every <see cref="Drawable.Update"/> loop until <see cref="EndConsumingText"/> is called.
738 /// </summary>
739 protected void BeginConsumingText()
740 {
741 consumingText = true;
742 Schedule(consumePendingText);
743 }
744
745 /// <summary>
746 /// Stops consuming text from an <see cref="ITextInputSource"/>.
747 /// </summary>
748 protected void EndConsumingText()
749 {
750 consumingText = false;
751 }
752
753 /// <summary>
754 /// Consumes any pending characters and adds them to the textbox if not <see cref="ReadOnly"/>.
755 /// </summary>
756 /// <returns>Whether any characters were consumed.</returns>
757 private void consumePendingText()
758 {
759 string pendingText = textInput?.GetPendingText();
760
761 if (!string.IsNullOrEmpty(pendingText) && !ReadOnly)
762 {
763 InsertString(pendingText);
764 OnUserTextAdded(pendingText);
765 }
766
767 if (consumingText)
768 Schedule(consumePendingText);
769 }
770
771 #region Input event handling
772
773 protected override bool OnKeyDown(KeyDownEvent e)
774 {
775 if (textInput?.ImeActive == true || ReadOnly) return true;
776
777 if (e.ControlPressed || e.SuperPressed || e.AltPressed)
778 return false;
779
780 // we only care about keys which can result in text output.
781 if (keyProducesCharacter(e.Key))
782 BeginConsumingText();
783
784 switch (e.Key)
785 {
786 case Key.Escape:
787 KillFocus();
788 return true;
789
790 case Key.KeypadEnter:
791 case Key.Enter:
792 Commit();
793 return true;
794
795 // avoid blocking certain keys which may be used during typing but don't produce characters.
796 case Key.BackSpace:
797 case Key.Delete:
798 return false;
799 }
800
801 return base.OnKeyDown(e) || consumingText;
802 }
803
804 private bool keyProducesCharacter(Key key) => (key == Key.Space || key >= Key.Keypad0 && key <= Key.NonUSBackSlash) && key != Key.KeypadEnter;
805
806 /// <summary>
807 /// Removes focus from this <see cref="TextBox"/> if it currently has focus.
808 /// </summary>
809 protected virtual void KillFocus() => killFocus();
810
811 private string lastCommitText;
812
813 private void killFocus()
814 {
815 var manager = GetContainingInputManager();
816 if (manager?.FocusedDrawable == this)
817 manager.ChangeFocus(null);
818 }
819
820 /// <summary>
821 /// Commits current text on this <see cref="TextBox"/> and releases focus if <see cref="ReleaseFocusOnCommit"/> is set.
822 /// </summary>
823 protected virtual void Commit()
824 {
825 if (ReleaseFocusOnCommit && HasFocus)
826 {
827 killFocus();
828 if (CommitOnFocusLost)
829 // the commit will happen as a result of the focus loss.
830 return;
831 }
832
833 bool isNew = text != lastCommitText;
834 lastCommitText = text;
835
836 OnTextCommitted(isNew);
837 OnCommit?.Invoke(this, isNew);
838 }
839
840 protected override void OnKeyUp(KeyUpEvent e)
841 {
842 if (!e.HasAnyKeyPressed)
843 EndConsumingText();
844
845 base.OnKeyUp(e);
846 }
847
848 protected override void OnDrag(DragEvent e)
849 {
850 //if (textInput?.ImeActive == true) return true;
851
852 if (ReadOnly)
853 return;
854
855 if (doubleClickWord != null)
856 {
857 //select words at a time
858 if (getCharacterClosestTo(e.MousePosition) > doubleClickWord[1])
859 {
860 selectionStart = doubleClickWord[0];
861 selectionEnd = findSeparatorIndex(text, getCharacterClosestTo(e.MousePosition) - 1, 1);
862 selectionEnd = selectionEnd >= 0 ? selectionEnd : text.Length;
863 }
864 else if (getCharacterClosestTo(e.MousePosition) < doubleClickWord[0])
865 {
866 selectionStart = doubleClickWord[1];
867 selectionEnd = findSeparatorIndex(text, getCharacterClosestTo(e.MousePosition), -1);
868 selectionEnd = selectionEnd >= 0 ? selectionEnd + 1 : 0;
869 }
870 else
871 {
872 //in the middle
873 selectionStart = doubleClickWord[0];
874 selectionEnd = doubleClickWord[1];
875 }
876
877 cursorAndLayout.Invalidate();
878 }
879 else
880 {
881 if (text.Length == 0) return;
882
883 selectionEnd = getCharacterClosestTo(e.MousePosition);
884 if (selectionLength > 0)
885 GetContainingInputManager().ChangeFocus(this);
886
887 cursorAndLayout.Invalidate();
888 }
889 }
890
891 protected override bool OnDragStart(DragStartEvent e)
892 {
893 if (HasFocus) return true;
894
895 Vector2 posDiff = e.MouseDownPosition - e.MousePosition;
896
897 return Math.Abs(posDiff.X) > Math.Abs(posDiff.Y);
898 }
899
900 protected override bool OnDoubleClick(DoubleClickEvent e)
901 {
902 if (textInput?.ImeActive == true) return true;
903
904 if (text.Length == 0) return true;
905
906 if (AllowClipboardExport)
907 {
908 int hover = Math.Min(text.Length - 1, getCharacterClosestTo(e.MousePosition));
909
910 int lastSeparator = findSeparatorIndex(text, hover, -1);
911 int nextSeparator = findSeparatorIndex(text, hover, 1);
912
913 selectionStart = lastSeparator >= 0 ? lastSeparator + 1 : 0;
914 selectionEnd = nextSeparator >= 0 ? nextSeparator : text.Length;
915 }
916 else
917 {
918 selectionStart = 0;
919 selectionEnd = text.Length;
920 }
921
922 //in order to keep the home word selected
923 doubleClickWord = new[] { selectionStart, selectionEnd };
924
925 cursorAndLayout.Invalidate();
926 return true;
927 }
928
929 private static int findSeparatorIndex(string input, int searchPos, int direction)
930 {
931 bool isLetterOrDigit = char.IsLetterOrDigit(input[searchPos]);
932
933 for (int i = searchPos; i >= 0 && i < input.Length; i += direction)
934 {
935 if (char.IsLetterOrDigit(input[i]) != isLetterOrDigit)
936 return i;
937 }
938
939 return -1;
940 }
941
942 protected override bool OnMouseDown(MouseDownEvent e)
943 {
944 if (textInput?.ImeActive == true || ReadOnly) return true;
945
946 selectionStart = selectionEnd = getCharacterClosestTo(e.MousePosition);
947
948 cursorAndLayout.Invalidate();
949
950 return false;
951 }
952
953 protected override void OnMouseUp(MouseUpEvent e)
954 {
955 doubleClickWord = null;
956 }
957
958 protected override void OnFocusLost(FocusLostEvent e)
959 {
960 unbindInput();
961
962 caret.Hide();
963 cursorAndLayout.Invalidate();
964
965 if (CommitOnFocusLost)
966 Commit();
967 }
968
969 public override bool AcceptsFocus => true;
970
971 protected override bool OnClick(ClickEvent e)
972 {
973 if (!ReadOnly && HasFocus)
974 textInput?.EnsureActivated();
975
976 return !ReadOnly;
977 }
978
979 protected override void OnFocus(FocusEvent e)
980 {
981 bindInput();
982
983 caret.Show();
984 cursorAndLayout.Invalidate();
985 }
986
987 #endregion
988
989 #region Native TextBox handling (platform-specific)
990
991 private void unbindInput()
992 {
993 textInput?.Deactivate();
994 }
995
996 private void bindInput()
997 {
998 textInput?.Activate();
999 }
1000
1001 private void onImeResult()
1002 {
1003 //we only succeeded if there is pending data in the textbox
1004 if (imeDrawables.Count > 0)
1005 {
1006 foreach (var d in imeDrawables)
1007 {
1008 d.Colour = Color4.White;
1009 d.FadeTo(1, 200, Easing.Out);
1010 }
1011 }
1012
1013 imeDrawables.Clear();
1014 }
1015
1016 private readonly List<Drawable> imeDrawables = new List<Drawable>();
1017
1018 private void onImeComposition(string s)
1019 {
1020 //search for unchanged characters..
1021 int matchCount = 0;
1022 bool matching = true;
1023
1024 int searchStart = text.Length - imeDrawables.Count;
1025
1026 for (int i = 0; i < s.Length; i++)
1027 {
1028 if (matching && searchStart + i < text.Length && i < s.Length && text[searchStart + i] == s[i])
1029 {
1030 matchCount = i + 1;
1031 continue;
1032 }
1033
1034 matching = false;
1035 }
1036
1037 var unmatchingCount = imeDrawables.Count - matchCount;
1038
1039 if (unmatchingCount > 0)
1040 {
1041 removeCharacters(unmatchingCount);
1042 imeDrawables.RemoveRange(matchCount, unmatchingCount);
1043 }
1044
1045 if (matchCount == s.Length)
1046 //in the case of backspacing (or a NOP), we can exit early here.
1047 return;
1048
1049 string insertedText = s.Substring(matchCount);
1050
1051 insertString(insertedText, d =>
1052 {
1053 d.Colour = Color4.Aqua;
1054 d.Alpha = 0.6f;
1055 imeDrawables.Add(d);
1056 });
1057
1058 OnUserTextAdded(insertedText);
1059 }
1060
1061 #endregion
1062 }
1063}