A game framework written with osu! in mind.
at master 1063 lines 35 kB view raw
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}