1 /// 2 module fluid.text_input; 3 4 import std.uni; 5 import std.utf; 6 import std.range; 7 import std.string; 8 import std.traits; 9 import std.datetime; 10 import std.algorithm; 11 import std.container.dlist; 12 13 import fluid.node; 14 import fluid.text; 15 import fluid.input; 16 import fluid.label; 17 import fluid.style; 18 import fluid.utils; 19 import fluid.scroll; 20 import fluid.actions; 21 import fluid.backend; 22 import fluid.structs; 23 import fluid.typeface; 24 import fluid.popup_frame; 25 26 27 @safe: 28 29 30 /// Constructor parameter, enables multiline input in `TextInput`. 31 auto multiline(bool value = true) { 32 33 struct Multiline { 34 35 bool value; 36 37 void apply(TextInput node) { 38 node.multiline = value; 39 } 40 41 } 42 43 return Multiline(value); 44 45 } 46 47 48 /// Text input field. 49 alias textInput = nodeBuilder!TextInput; 50 alias lineInput = nodeBuilder!TextInput; 51 alias multilineInput = nodeBuilder!(TextInput, (a) { 52 a.multiline = true; 53 }); 54 55 /// ditto 56 class TextInput : InputNode!Node, FluidScrollable { 57 58 mixin enableInputActions; 59 60 public { 61 62 /// Size of the field. 63 auto size = Vector2(200, 0); 64 65 /// A placeholder text for the field, displayed when the field is empty. Style using `emptyStyle`. 66 Rope placeholder; 67 68 /// Time of the last interaction with the input. 69 SysTime lastTouch; 70 71 /// Reference horizontal (X) position for vertical movement. Relative to the input's top-left corner. 72 /// 73 /// To make sure that vertical navigation via up/down arrows stays in the same column as it traverses lines of 74 /// varying width, a reference position is saved to match the visual position of the last column. This is then 75 /// used to match against characters on adjacent lines to find the one that is the closest. This ensures that 76 /// even if cursor travels a large vertical distance, it stays close to the original horizontal position, 77 /// without sliding to the left or right in the process. 78 /// 79 /// `horizontalAnchor` is updated any time the cursor moves horizontally, including mouse navigation. 80 float horizontalAnchor; 81 82 /// Context menu for this input. 83 PopupFrame contextMenu; 84 85 /// Underlying label controlling the content. 86 ContentLabel contentLabel; 87 88 /// Maximum entries in the history. 89 int maxHistorySize = 256; 90 91 } 92 93 protected { 94 95 static struct HistoryEntry { 96 97 Rope value; 98 size_t selectionStart; 99 size_t selectionEnd; 100 101 /// If true, the entry results from an action that was executed immediately after the last action, without 102 /// changing caret position in the meantime. 103 bool isContinuous; 104 105 /// Change made by this entry. 106 /// 107 /// `first` and `second` should represent the old and new value respectively; `second` is effectively a 108 /// substring of `value`. 109 Rope.DiffRegion diff; 110 111 /// A history entry is "additive" if it adds any new content to the input. An entry is "subtractive" if it 112 /// removes any part of the input. An entry that replaces content is simultaneously additive and 113 /// subtractive. 114 /// 115 /// See_Also: `setPreviousEntry`, `canMergeWith` 116 bool isAdditive; 117 118 /// ditto. 119 bool isSubtractive; 120 121 /// Set `isAdditive` and `isSubtractive` based on the given text representing the last input. 122 void setPreviousEntry(HistoryEntry entry) { 123 124 setPreviousEntry(entry.value); 125 126 } 127 128 /// ditto 129 void setPreviousEntry(Rope previousValue) { 130 131 this.diff = previousValue.diff(value); 132 this.isAdditive = diff.second.length != 0; 133 this.isSubtractive = diff.first.length != 0; 134 135 } 136 137 /// Check if this entry can be merged with (newer) entry given its text content. This is used to combine 138 /// runs of similar actions together, for example when typing a word, the whole word will form a single 139 /// entry, instead of creating separate entries per character. 140 /// 141 /// Two entries can be combined if they are: 142 /// 143 /// 1. Both additive, and the latter is not subtractive. This combines runs input, including if the first 144 /// item in the run replaces some text. However, replacing text will break an existing chain of actions. 145 /// 2. Both subtractive, and neither is additive. 146 /// 147 /// See_Also: `isAdditive` 148 bool canMergeWith(Rope nextValue) const { 149 150 // Create a dummy entry based on the text 151 auto nextEntry = HistoryEntry(nextValue, 0, 0); 152 nextEntry.setPreviousEntry(value); 153 154 return canMergeWith(nextEntry); 155 156 } 157 158 /// ditto 159 bool canMergeWith(HistoryEntry nextEntry) const { 160 161 const mergeAdditive = this.isAdditive 162 && nextEntry.isAdditive 163 && !nextEntry.isSubtractive; 164 165 if (mergeAdditive) return true; 166 167 const mergeSubtractive = !this.isAdditive 168 && this.isSubtractive 169 && !nextEntry.isAdditive 170 && nextEntry.isSubtractive; 171 172 return mergeSubtractive; 173 174 } 175 176 } 177 178 /// If true, current movement action is performed while selecting. 179 bool selectionMovement; 180 181 /// Last padding box assigned to this node, with scroll applied. 182 Rectangle _inner = Rectangle(0, 0, 0, 0); 183 184 /// If true, the caret index has not changed since last `pushSnapshot`. 185 bool _isContinuous; 186 187 /// Current action history, expressed as two stacks, indicating undoable and redoable actions, controllable via 188 /// `snapshot`, `pushSnapshot`, `undo` and `redo`. 189 DList!HistoryEntry _undoStack; 190 191 /// ditto 192 DList!HistoryEntry _redoStack; 193 194 } 195 196 private { 197 198 /// Action used to keep the text input in view. 199 ScrollIntoViewAction _scrollAction; 200 201 /// Buffer used to store recently inserted text. 202 /// See_Also: `buffer` 203 char[] _buffer; 204 205 /// Number of bytes stored in the bufer. 206 size_t _usedBufferSize; 207 208 /// Node the buffer is stored in. 209 RopeNode* _bufferNode; 210 invariant(_bufferNode is null || _bufferNode.value.sameTail(_buffer[0 .. _usedBufferSize]), 211 "_bufferNode must be in sync with _buffer"); 212 213 /// Value of the text input. 214 Rope _value; 215 216 /// Available horizontal space. 217 float _availableWidth = float.nan; 218 219 /// Visual position of the caret. 220 Vector2 _caretPosition; 221 222 /// Index of the caret. 223 ptrdiff_t _caretIndex; 224 225 /// Reference point; beginning of selection. Set to -1 if there is no start. 226 ptrdiff_t _selectionStart; 227 228 /// Current horizontal visual offset of the label. 229 float _scroll = 0; 230 231 } 232 233 /// Create a text input. 234 /// Params: 235 /// placeholder = Placeholder text for the field. 236 /// submitted = Callback for when the field is submitted. 237 this(string placeholder = "", void delegate() @trusted submitted = null) { 238 239 import fluid.button; 240 241 this.placeholder = placeholder; 242 this.submitted = submitted; 243 this.lastTouch = Clock.currTime; 244 this.contentLabel = new typeof(contentLabel); 245 246 // Make single line the default 247 contentLabel.isWrapDisabled = true; 248 249 // Enable edit mode 250 contentLabel.text.hasFastEdits = true; 251 252 // Create the context menu 253 this.contextMenu = popupFrame( 254 button( 255 .layout!"fill", 256 "Cut", 257 delegate { 258 cut(); 259 contextMenu.hide(); 260 } 261 ), 262 button( 263 .layout!"fill", 264 "Copy", 265 delegate { 266 copy(); 267 contextMenu.hide(); 268 } 269 ), 270 button( 271 .layout!"fill", 272 "Paste", 273 delegate { 274 paste(); 275 contextMenu.hide(); 276 } 277 ), 278 ); 279 280 } 281 282 static class ContentLabel : Label { 283 284 this() { 285 286 super(""); 287 288 } 289 290 override bool hoveredImpl(Rectangle, Vector2) const { 291 292 return false; 293 294 } 295 296 override void drawImpl(Rectangle outer, Rectangle inner) { 297 298 // Don't draw background 299 const style = pickStyle(); 300 text.draw(style, inner.start); 301 302 } 303 304 override void reloadStyles() { 305 306 // Do not load styles 307 308 } 309 310 override Style pickStyle() { 311 312 // Always use default style 313 return style; 314 315 } 316 317 } 318 319 /// Mark the text input as modified. 320 void touch() { 321 322 lastTouch = Clock.currTime; 323 scrollIntoView(); 324 325 } 326 327 /// Mark the text input as modified and fire the "changed" event. 328 void touchText() { 329 330 touch(); 331 if (changed) changed(); 332 333 } 334 335 /// Scroll ancestors so the text input becomes visible. 336 /// 337 /// `TextInput` keeps its own instance of `ScrollIntoViewAction`, reusing it every time it is needed. 338 /// 339 /// Params: 340 /// alignToTop = If true, the top of the element will be aligned to the top of the scrollable area. 341 ScrollIntoViewAction scrollIntoView(bool alignToTop = false) { 342 343 // Create the action 344 if (!_scrollAction) { 345 _scrollAction = .scrollIntoView(this, alignToTop); 346 } 347 else { 348 _scrollAction.reset(alignToTop); 349 queueAction(_scrollAction); 350 } 351 352 return _scrollAction; 353 354 } 355 356 /// Value written in the input. 357 inout(Rope) value() inout { 358 359 return _value; 360 361 } 362 363 /// ditto 364 Rope value(Rope newValue) { 365 366 auto withoutLineFeeds = Typeface.lineSplitter(newValue).joiner; 367 368 // Single line mode — filter vertical space out 369 if (!multiline && !newValue.equal(withoutLineFeeds)) { 370 371 newValue = Typeface.lineSplitter(newValue).join(' '); 372 373 } 374 375 _value = newValue; 376 _bufferNode = null; 377 updateSize(); 378 return value; 379 380 } 381 382 /// ditto 383 Rope value(const(char)[] value) { 384 385 return this.value(Rope(value)); 386 387 } 388 389 /// If true, this input is currently empty. 390 bool isEmpty() const { 391 392 return value == ""; 393 394 } 395 396 /// If true, this input accepts multiple inputs in the input; pressing "enter" will start a new line. 397 /// 398 /// Even if multiline is off, the value may still contain line feeds if inserted from code. 399 bool multiline() const { 400 401 return !contentLabel.isWrapDisabled; 402 403 } 404 405 /// ditto 406 bool multiline(bool value) { 407 408 contentLabel.isWrapDisabled = !value; 409 return value; 410 411 } 412 413 /// Current horizontal visual offset of the label. 414 float scroll() const { 415 416 return _scroll; 417 418 } 419 420 /// Set scroll value. 421 float scroll(float value) { 422 423 const limit = max(0, contentLabel.minSize.x - _inner.w); 424 425 return _scroll = value.clamp(0, limit); 426 427 } 428 429 /// 430 bool canScroll(Vector2 value) const { 431 432 return clamp(scroll + value.x, 0, _availableWidth) != scroll; 433 434 } 435 436 /// Get the current style for the label. 437 /// Params: 438 /// style = Current style of the TextInput. 439 Style pickLabelStyle(Style style) { 440 441 // Pick style from the input 442 auto result = style; 443 444 // Remove spacing 445 result.margin = 0; 446 result.padding = 0; 447 result.border = 0; 448 449 return result; 450 451 } 452 453 final Style pickLabelStyle() { 454 455 return pickLabelStyle(pickStyle); 456 457 } 458 459 /// Get or set text preceding the caret. 460 Rope valueBeforeCaret() const { 461 462 return value[0 .. caretIndex]; 463 464 } 465 466 /// ditto 467 Rope valueBeforeCaret(Rope newValue) { 468 469 // Replace the data 470 if (valueAfterCaret.empty) 471 value = newValue; 472 else 473 value = newValue ~ valueAfterCaret; 474 475 caretIndex = newValue.length; 476 updateSize(); 477 478 return value[0 .. caretIndex]; 479 480 } 481 482 /// ditto 483 Rope valueBeforeCaret(const(char)[] newValue) { 484 485 return valueBeforeCaret(Rope(newValue)); 486 487 } 488 489 /// Get or set currently selected text. 490 Rope selectedValue() inout { 491 492 return value[selectionLowIndex .. selectionHighIndex]; 493 494 } 495 496 /// ditto 497 Rope selectedValue(Rope newValue) { 498 499 const isLow = caretIndex == selectionStart; 500 const low = selectionLowIndex; 501 const high = selectionHighIndex; 502 503 value = value.replace(low, high, newValue); 504 caretIndex = low + newValue.length; 505 updateSize(); 506 clearSelection(); 507 508 return value[low .. low + newValue.length]; 509 510 } 511 512 /// ditto 513 Rope selectedValue(const(char)[] newValue) { 514 515 return selectedValue(Rope(newValue)); 516 517 } 518 519 /// Get or set text following the caret. 520 Rope valueAfterCaret() inout { 521 522 return value[caretIndex .. $]; 523 524 } 525 526 /// ditto 527 Rope valueAfterCaret(Rope newValue) { 528 529 // Replace the data 530 if (valueBeforeCaret.empty) 531 value = newValue; 532 else 533 value = valueBeforeCaret ~ newValue; 534 535 updateSize(); 536 537 return value[caretIndex .. $]; 538 539 } 540 541 /// ditto 542 Rope valueAfterCaret(const(char)[] value) { 543 544 return valueAfterCaret(Rope(value)); 545 546 } 547 548 unittest { 549 550 auto root = textInput(); 551 552 root.value = "hello wörld!"; 553 assert(root.value == "hello wörld!"); 554 555 root.value = "hello wörld!\n"; 556 assert(root.value == "hello wörld! "); 557 558 root.value = "hello wörld!\r\n"; 559 assert(root.value == "hello wörld! "); 560 561 root.value = "hello wörld!\v"; 562 assert(root.value == "hello wörld! "); 563 564 } 565 566 unittest { 567 568 auto root = textInput(.multiline); 569 570 root.value = "hello wörld!"; 571 assert(root.value == "hello wörld!"); 572 573 root.value = "hello wörld!\n"; 574 assert(root.value == "hello wörld!\n"); 575 576 root.value = "hello wörld!\r\n"; 577 assert(root.value == "hello wörld!\r\n"); 578 579 root.value = "hello wörld!\v"; 580 assert(root.value == "hello wörld!\v"); 581 582 } 583 584 /// Visual position of the caret, relative to the top-left corner of the input. 585 Vector2 caretPosition() const { 586 587 // Calculated in caretPositionImpl 588 return _caretPosition; 589 590 } 591 592 /// Index of the character, byte-wise. 593 ptrdiff_t caretIndex() const { 594 595 return _caretIndex.clamp(0, value.length); 596 597 } 598 599 /// ditto 600 ptrdiff_t caretIndex(ptrdiff_t index) { 601 602 if (!isSelecting) { 603 _selectionStart = index; 604 } 605 606 touch(); 607 _bufferNode = null; 608 _isContinuous = false; 609 return _caretIndex = index; 610 611 } 612 613 /// If true, there's an active selection. 614 bool isSelecting() const { 615 616 return selectionStart != caretIndex || selectionMovement; 617 618 } 619 620 /// Low index of the selection, left boundary, first index. 621 ptrdiff_t selectionLowIndex() const { 622 623 return min(selectionStart, selectionEnd); 624 625 } 626 627 /// High index of the selection, right boundary, second index. 628 ptrdiff_t selectionHighIndex() const { 629 630 return max(selectionStart, selectionEnd); 631 632 } 633 634 /// Point where selection begins. Caret is the other end of the selection. 635 /// 636 /// Note that `selectionStart` may be greater than `selectionEnd`. If you need them in order, use 637 /// `selectionLowIndex` and `selectionHighIndex`. 638 ptrdiff_t selectionStart() const { 639 640 // Selection is present 641 return _selectionStart.clamp(0, value.length); 642 643 } 644 645 /// ditto 646 ptrdiff_t selectionStart(ptrdiff_t value) { 647 648 return _selectionStart = value; 649 650 } 651 652 /// Point where selection ends. Corresponds to caret position. 653 alias selectionEnd = caretIndex; 654 655 /// Select a part of text. This is preferred to setting `selectionStart` & `selectionEnd` directly, since the two 656 /// properties are synchronized together and a change might be ignored. 657 void selectSlice(size_t start, size_t end) 658 in (end <= value.length, format!"Slice [%s .. %s] exceeds textInput value length of %s"(start, end, value.length)) 659 do { 660 661 selectionEnd = end; 662 selectionStart = start; 663 664 } 665 666 unittest { 667 668 auto root = textInput(); 669 root.value = "foo bar baz"; 670 root.selectSlice(0, 3); 671 assert(root.selectedValue == "foo"); 672 673 root.caretIndex = 4; 674 root.selectSlice(4, 7); 675 assert(root.selectedValue == "bar"); 676 677 root.caretIndex = 11; 678 root.selectSlice(8, 11); 679 assert(root.selectedValue == "baz"); 680 681 } 682 683 /// 684 void clearSelection() { 685 686 _selectionStart = _caretIndex; 687 688 } 689 690 /// Clear selection if selection movement is disabled. 691 protected void moveOrClearSelection() { 692 693 if (!selectionMovement) { 694 695 clearSelection(); 696 697 } 698 699 } 700 701 protected override void resizeImpl(Vector2 area) { 702 703 import std.math : isNaN; 704 705 // Set the size 706 minSize = size; 707 708 // Set the label text 709 contentLabel.text = value == "" ? placeholder : value; 710 711 const isFill = layout.nodeAlign[0] == NodeAlign.fill; 712 713 _availableWidth = isFill 714 ? area.x 715 : size.x; 716 717 const textArea = multiline 718 ? Vector2(_availableWidth, area.y) 719 : Vector2(0, size.y); 720 721 // Resize the label, and remove the spacing 722 contentLabel.style = pickLabelStyle(style); 723 contentLabel.resize(tree, theme, textArea); 724 725 const minLines = multiline ? 3 : 1; 726 727 // Set height to at least the font size, or total text size 728 minSize.y = max(minSize.y, style.getTypeface.lineHeight * minLines, contentLabel.minSize.y); 729 730 // Locate the cursor 731 updateCaretPosition(); 732 733 // Horizontal anchor is not set, update it 734 if (horizontalAnchor.isNaN) 735 horizontalAnchor = caretPosition.x; 736 737 } 738 739 unittest { 740 741 auto io = new HeadlessBackend(Vector2(800, 600)); 742 auto root = textInput( 743 .layout!"fill", 744 .multiline, 745 .nullTheme, 746 "This placeholder exceeds the default size of a text input." 747 ); 748 749 root.io = io; 750 root.draw(); 751 752 Vector2 textSize() { 753 754 return root.contentLabel.minSize; 755 756 } 757 758 assert(textSize.x > 200); 759 assert(textSize.x > root.size.x); 760 761 io.nextFrame; 762 root.placeholder = ""; 763 root.updateSize(); 764 root.draw(); 765 766 assert(root.caretPosition.x < 1); 767 assert(textSize.x < 1); 768 769 io.nextFrame; 770 root.value = "This value exceeds the default size of a text input."; 771 root.updateSize(); 772 root.caretToEnd(); 773 root.draw(); 774 775 assert(root.caretPosition.x > 200); 776 assert(textSize.x > 200); 777 assert(textSize.x > root.size.x); 778 779 io.nextFrame; 780 root.value = ("This value is long enough to start a new line in the output. To make sure of it, here's " 781 ~ "some more text. And more."); 782 root.updateSize(); 783 root.draw(); 784 785 assert(textSize.x > root.size.x); 786 assert(textSize.x <= 800); 787 assert(textSize.y >= root.style.getTypeface.lineHeight * 2); 788 assert(root.minSize.y >= textSize.y); 789 790 } 791 792 /// Update the caret position to match the caret index. 793 /// 794 /// ## preferNextLine 795 /// 796 /// Determines if, in case text wraps over the new line, and the caret is in an ambiguous position, the caret 797 /// will move to the next line, or stay on the previous one. Usually `false`, except for arrow keys and the 798 /// "home" key. 799 /// 800 /// In cases where the text wraps over to the new line due to lack of space, the implied line break creates an 801 /// ambiguous position for the caret. The caret may be placed either at the end of the original line, or be put 802 /// on the newly created line: 803 /// 804 /// --- 805 /// Lorem ipsum dolor sit amet, consectetur | 806 /// |adipiscing elit, sed do eiusmod tempor 807 /// --- 808 /// 809 /// Depending on the situation, either position may be preferable. Keep in mind that the caret position influences 810 /// further movement, particularly when navigating using the down and up arrows. In case the caret is at the 811 /// end of the line, it should stay close to the end, but when it's at the beginning, it should stay close to the 812 /// start. 813 /// 814 /// This is not an issue at all on explicitly created lines, since the caret position is easily decided upon 815 /// depending if it is preceding the line break, or if it's following one. This property is only used for implicitly 816 /// created lines. 817 void updateCaretPosition(bool preferNextLine = false) { 818 819 import std.math : isNaN; 820 821 // No available width, waiting for resize 822 if (_availableWidth.isNaN) { 823 _caretPosition.x = float.nan; 824 return; 825 } 826 827 _caretPosition = caretPositionImpl(_availableWidth, preferNextLine); 828 829 const scrolledCaret = caretPosition.x - scroll; 830 831 // Scroll to make sure the caret is always in view 832 const scrollOffset 833 = scrolledCaret > _inner.width ? scrolledCaret - _inner.width 834 : scrolledCaret < 0 ? scrolledCaret 835 : 0; 836 837 // Set the scroll 838 scroll = multiline 839 ? 0 840 : scroll + scrollOffset; 841 842 } 843 844 /// Find the closest index to the given position. 845 /// Returns: Index of the character. The index may be equal to character length. 846 size_t nearestCharacter(Vector2 needle) { 847 848 import std.math : abs; 849 850 auto ruler = textRuler(); 851 auto typeface = ruler.typeface; 852 853 struct Position { 854 size_t index; 855 Vector2 position; 856 } 857 858 /// Returns the position (inside the word) of the character that is the closest to the needle. 859 Position closest(Vector2 startPosition, Vector2 endPosition, const Rope word) { 860 861 // Needle is before or after the word 862 if (needle.x <= startPosition.x) return Position(0, startPosition); 863 if (needle.x >= endPosition.x) return Position(word.length, endPosition); 864 865 size_t index; 866 auto match = Position(0, startPosition); 867 868 // Search inside the word 869 while (index < word.length) { 870 871 decode(word[], index); // index by reference 872 873 auto size = typeface.measure(word[0..index]); 874 auto end = startPosition.x + size.x; 875 auto half = (match.position.x + end)/2; 876 877 // Hit left side of this character, or right side of the previous, return the previous character 878 if (needle.x < half) break; 879 880 match.index = index; 881 match.position.x = startPosition.x + size.x; 882 883 } 884 885 return match; 886 887 } 888 889 auto result = Position(0, Vector2(float.infinity, float.infinity)); 890 891 // Search for a matching character on adjacent lines 892 search: foreach (index, line; typeface.lineSplitterIndex(value[])) { 893 894 ruler.startLine(); 895 896 // Each word is a single, unbreakable unit 897 foreach (word, penPosition; typeface.eachWord(ruler, line, multiline)) { 898 899 scope (exit) index += word.length; 900 901 // Find the middle of the word to use as a reference for vertical search 902 const middleY = ruler.caret.center.y; 903 904 // Skip this word if the closest match is closer vertically 905 if (abs(result.position.y - needle.y) < abs(middleY - needle.y)) continue; 906 907 // Find the words' closest horizontal position 908 const newLine = ruler.wordLineIndex == 1; 909 const startPosition = Vector2(penPosition.x, middleY); 910 const endPosition = Vector2(ruler.penPosition.x, middleY); 911 const reference = closest(startPosition, endPosition, word); 912 913 // Skip if the closest match is still closer than the chosen reference 914 if (!newLine && abs(result.position.x - needle.x) < abs(reference.position.x - needle.x)) continue; 915 916 // Save the result if it's better 917 result = reference; 918 result.index += index; 919 920 } 921 922 } 923 924 return result.index; 925 926 } 927 928 /// Get the current buffer. 929 /// 930 /// The buffer is used to store all content inserted through `push`. 931 protected inout(char)[] buffer() inout { 932 933 return _buffer; 934 935 } 936 937 /// Get the used size of the buffer. 938 protected ref inout(size_t) usedBufferSize() inout { 939 940 return _usedBufferSize; 941 942 } 943 944 /// Get the filled part of the buffer. 945 protected inout(char)[] usedBuffer() inout { 946 947 return _buffer[0 .. _usedBufferSize]; 948 949 } 950 951 /// Get the empty part of the buffer. 952 protected inout(char)[] freeBuffer() inout { 953 954 return _buffer[_usedBufferSize .. $]; 955 956 } 957 958 /// Request a new or a larger buffer. 959 /// Params: 960 /// minimumSize = Minimum size to allocate for the buffer. 961 protected void newBuffer(size_t minimumSize = 64) { 962 963 const newSize = max(minimumSize, 64); 964 965 _buffer = new char[newSize]; 966 usedBufferSize = 0; 967 968 } 969 970 protected Vector2 caretPositionImpl(float textWidth, bool preferNextLine) { 971 972 Rope unbreakableChars(Rope value) { 973 974 // Split on lines 975 auto lines = Typeface.lineSplitter(value); 976 if (lines.empty) return value.init; 977 978 // Split on words 979 auto chunks = Typeface.defaultWordChunks(lines.front); 980 if (chunks.empty) return value.init; 981 982 // Return empty string if the result starts with whitespace 983 if (chunks.front.byDchar.front.isWhite) return value.init; 984 985 // Return first word only 986 return chunks.front; 987 988 } 989 990 // Check if the caret follows unbreakable characters 991 const head = unbreakableChars( 992 valueBeforeCaret.wordBack(true) 993 ); 994 995 // If the caret is surrounded by unbreakable characters, include them in the output to make sure the 996 // word is wrapped correctly 997 const tail = preferNextLine || !head.empty 998 ? unbreakableChars(valueAfterCaret) 999 : Rope.init; 1000 1001 auto typeface = style.getTypeface; 1002 auto ruler = textRuler(); 1003 auto slice = value[0 .. caretIndex + tail.length]; 1004 1005 // Measure text until the caret; include the word that follows to keep proper wrapping 1006 typeface.measure(ruler, slice, multiline); 1007 1008 auto caretPosition = ruler.caret.start; 1009 1010 // Measure the word itself, and remove it 1011 caretPosition.x -= typeface.measure(tail[]).x; 1012 1013 return caretPosition; 1014 1015 } 1016 1017 protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted { 1018 1019 auto style = pickStyle(); 1020 1021 // Fill the background 1022 style.drawBackground(tree.io, outer); 1023 1024 // Copy style to the label 1025 contentLabel.style = pickLabelStyle(style); 1026 1027 // Scroll the inner rectangle 1028 auto scrolledInner = inner; 1029 scrolledInner.x -= scroll; 1030 1031 // Save the inner box 1032 _inner = scrolledInner; 1033 1034 // Increase the size of the inner box so that tree doesn't turn on scissors mode on its own 1035 scrolledInner.w += scroll; 1036 1037 const lastScissors = tree.pushScissors(outer); 1038 scope (exit) tree.popScissors(lastScissors); 1039 1040 // Draw the contents 1041 drawContents(inner, scrolledInner); 1042 1043 } 1044 1045 protected void drawContents(Rectangle inner, Rectangle scrolledInner) { 1046 1047 // Draw selection 1048 drawSelection(scrolledInner); 1049 1050 // Draw the text 1051 contentLabel.draw(scrolledInner); 1052 1053 // Draw the caret 1054 drawCaret(scrolledInner); 1055 1056 } 1057 1058 protected void drawCaret(Rectangle inner) { 1059 1060 // Ignore the rest if the node isn't focused 1061 if (!isFocused || isDisabledInherited) return; 1062 1063 // Add a blinking caret 1064 if (showCaret) { 1065 1066 const lineHeight = style.getTypeface.lineHeight; 1067 const margin = lineHeight / 10f; 1068 const relativeCaretPosition = this.caretPosition(); 1069 const caretPosition = start(inner) + relativeCaretPosition; 1070 1071 // Draw the caret 1072 io.drawLine( 1073 caretPosition + Vector2(0, margin), 1074 caretPosition - Vector2(0, margin - lineHeight), 1075 style.textColor, 1076 ); 1077 1078 } 1079 1080 } 1081 1082 override Rectangle focusBoxImpl(Rectangle inner) const { 1083 1084 const lineHeight = style.getTypeface.lineHeight; 1085 const position = inner.start + caretPosition; 1086 1087 return Rectangle( 1088 position.tupleof, 1089 1, lineHeight 1090 ); 1091 1092 } 1093 1094 /// Get an appropriate text ruler for this input. 1095 protected TextRuler textRuler() { 1096 1097 return TextRuler(style.getTypeface, multiline ? _availableWidth : float.nan); 1098 1099 } 1100 1101 /// Draw selection, if applicable. 1102 protected void drawSelection(Rectangle inner) { 1103 1104 // Ignore if selection is empty 1105 if (selectionStart == selectionEnd) return; 1106 1107 const low = selectionLowIndex; 1108 const high = selectionHighIndex; 1109 1110 auto style = pickStyle(); 1111 auto typeface = style.getTypeface; 1112 auto ruler = textRuler(); 1113 1114 Vector2 lineStart; 1115 Vector2 lineEnd; 1116 1117 // Run through the text 1118 foreach (index, line; typeface.lineSplitterIndex(value)) { 1119 1120 ruler.startLine(); 1121 1122 // Each word is a single, unbreakable unit 1123 foreach (word, penPosition; typeface.eachWord(ruler, line, multiline)) { 1124 1125 const caret = ruler.caret(penPosition); 1126 const startIndex = index; 1127 const endIndex = index = index + word.length; 1128 1129 const newLine = ruler.wordLineIndex == 1; 1130 1131 scope (exit) lineEnd = ruler.caret.end; 1132 1133 // New line started, flush the line 1134 if (newLine && startIndex > low) { 1135 1136 const rect = Rectangle( 1137 (inner.start + lineStart).tupleof, 1138 (lineEnd - lineStart).tupleof 1139 ); 1140 1141 lineStart = caret.start; 1142 io.drawRectangle(rect, style.selectionBackgroundColor); 1143 1144 } 1145 1146 // Selection starts here 1147 if (startIndex <= low && low <= endIndex) { 1148 1149 const dent = typeface.measure(word[0 .. low - startIndex]); 1150 1151 lineStart = caret.start + Vector2(dent.x, 0); 1152 1153 } 1154 1155 // Selection ends here 1156 if (startIndex <= high && high <= endIndex) { 1157 1158 const dent = typeface.measure(word[0 .. high - startIndex]); 1159 const lineEnd = caret.end + Vector2(dent.x, 0); 1160 const rect = Rectangle( 1161 (inner.start + lineStart).tupleof, 1162 (lineEnd - lineStart).tupleof 1163 ); 1164 1165 io.drawRectangle(rect, style.selectionBackgroundColor); 1166 return; 1167 1168 } 1169 1170 } 1171 1172 } 1173 1174 } 1175 1176 protected bool showCaret() { 1177 1178 auto timeSecs = (Clock.currTime - lastTouch).total!"seconds"; 1179 1180 // Add a blinking caret if there is no selection 1181 return selectionStart == selectionEnd && timeSecs % 2 == 0; 1182 1183 } 1184 1185 protected override bool keyboardImpl() { 1186 1187 import std.uni : isAlpha, isWhite; 1188 import std.range : back; 1189 1190 bool changed; 1191 1192 // Get pressed key 1193 while (true) { 1194 1195 // Read text 1196 if (const key = io.inputCharacter) { 1197 1198 // Append to char arrays 1199 push(key); 1200 changed = true; 1201 1202 } 1203 1204 // Stop if there's nothing left 1205 else break; 1206 1207 } 1208 1209 // Typed something 1210 if (changed) { 1211 1212 // Trigger the callback 1213 touchText(); 1214 1215 return true; 1216 1217 } 1218 1219 return true; 1220 1221 } 1222 1223 unittest { 1224 1225 auto root = textInput(); 1226 root.value = "Ho"; 1227 1228 root.caretIndex = 1; 1229 root.push("e"); 1230 assert(root.value.byNode.equal(["H", "e", "o"])); 1231 1232 root.push("n"); 1233 1234 assert(root.value.byNode.equal(["H", "en", "o"])); 1235 1236 root.push("l"); 1237 assert(root.value.byNode.equal(["H", "enl", "o"])); 1238 1239 // Create enough text to fill the buffer 1240 // A new node should be created as a result 1241 auto bufferFiller = 'A'.repeat(root.freeBuffer.length).array; 1242 1243 root.push(bufferFiller); 1244 assert(root.value.byNode.equal(["H", "enl", bufferFiller, "o"])); 1245 1246 // Undo all pushes until the initial fill 1247 root.undo(); 1248 assert(root.value == "Ho"); 1249 assert(root.valueBeforeCaret == "H"); 1250 1251 // Undo will not clear the initial value 1252 root.undo(); 1253 assert(root.value == "Ho"); 1254 assert(root.valueBeforeCaret == "H"); 1255 1256 // The above undo does not add a new redo stack entry; effectively, this redo cancels both undo actions above 1257 root.redo(); 1258 assert(root.value.byNode.equal(["H", "enl", bufferFiller, "o"])); 1259 1260 } 1261 1262 unittest { 1263 1264 auto io = new HeadlessBackend; 1265 auto root = textInput("placeholder"); 1266 1267 root.io = io; 1268 1269 // Empty text 1270 { 1271 root.draw(); 1272 1273 assert(root.value == ""); 1274 assert(root.contentLabel.text == "placeholder"); 1275 assert(root.isEmpty); 1276 } 1277 1278 // Focus the box and input stuff 1279 { 1280 io.nextFrame; 1281 io.inputCharacter("¡Hola, mundo!"); 1282 root.focus(); 1283 root.draw(); 1284 1285 assert(root.value == "¡Hola, mundo!"); 1286 } 1287 1288 // The text will be displayed the next frame 1289 { 1290 io.nextFrame; 1291 root.draw(); 1292 1293 assert(root.contentLabel.text == "¡Hola, mundo!"); 1294 assert(root.isFocused); 1295 } 1296 1297 } 1298 1299 /// Push a character or string to the input. 1300 void push(dchar character) { 1301 1302 char[4] buffer; 1303 1304 auto size = buffer.encode(character); 1305 push(buffer[0..size]); 1306 1307 } 1308 1309 /// ditto 1310 void push(scope const(char)[] ch) 1311 out (; _bufferNode, "_bufferNode must exist after pushing to buffer") 1312 do { 1313 1314 // Move the buffer node into here; move it back when done 1315 auto bufferNode = _bufferNode; 1316 _bufferNode = null; 1317 scope (exit) _bufferNode = bufferNode; 1318 1319 // Not enough space in the buffer, allocate more 1320 if (freeBuffer.length <= ch.length) { 1321 1322 newBuffer(ch.length); 1323 bufferNode = null; 1324 1325 } 1326 1327 auto slice = freeBuffer[0 .. ch.length]; 1328 1329 // Save the data in the buffer 1330 slice[] = ch; 1331 _usedBufferSize += ch.length; 1332 1333 // Selection is active, overwrite it 1334 if (isSelecting) { 1335 1336 bufferNode = new RopeNode(slice); 1337 push(Rope(bufferNode)); 1338 return; 1339 1340 } 1341 1342 // The above `if` handles the one case where `push` doesn't directly add new characters to the text. 1343 // From here, appending can be optimized by memorizing the node we create to add the text, and reusing it 1344 // afterwards. This way, we avoid creating many one element nodes. 1345 1346 size_t originalLength; 1347 1348 // Make sure there is a node to write to 1349 if (!bufferNode) 1350 bufferNode = new RopeNode(slice); 1351 1352 // If writing in a single sequence, reuse the last inserted node 1353 else { 1354 1355 originalLength = bufferNode.length; 1356 1357 // Append the character to its value 1358 // The bufferNode will always share tail with the buffer 1359 bufferNode.value = usedBuffer[$ - originalLength - ch.length .. $]; 1360 1361 } 1362 1363 // Save previous value in undo stack 1364 const previousState = snapshot(); 1365 scope (success) pushSnapshot(previousState); 1366 1367 // Insert the text by replacing the old node, if present 1368 value = value.replace(caretIndex - originalLength, caretIndex, Rope(bufferNode)); 1369 assert(value.isBalanced); 1370 1371 // Move the caret 1372 caretIndex = caretIndex + ch.length; 1373 updateCaretPosition(); 1374 horizontalAnchor = caretPosition.x; 1375 1376 } 1377 1378 /// ditto 1379 void push(Rope text) { 1380 1381 // Save previous value in undo stack 1382 const previousState = snapshot(); 1383 scope (success) pushSnapshot(previousState); 1384 1385 // If selection is active, overwrite the selection 1386 if (isSelecting) { 1387 1388 // Override with the character 1389 selectedValue = text; 1390 clearSelection(); 1391 1392 } 1393 1394 // Insert the character before caret 1395 else if (valueBeforeCaret.length) { 1396 1397 valueBeforeCaret = valueBeforeCaret ~ text; 1398 touch(); 1399 1400 } 1401 1402 else { 1403 1404 valueBeforeCaret = text; 1405 touch(); 1406 1407 } 1408 1409 updateCaretPosition(); 1410 horizontalAnchor = caretPosition.x; 1411 1412 } 1413 1414 /// Start a new line 1415 @(FluidInputAction.breakLine) 1416 protected bool breakLine() { 1417 1418 if (!multiline) return false; 1419 1420 auto snap = snapshot(); 1421 push('\n'); 1422 forcePushSnapshot(snap); 1423 1424 return true; 1425 1426 } 1427 1428 unittest { 1429 1430 auto root = textInput(); 1431 1432 root.push("hello"); 1433 root.runInputAction!(FluidInputAction.breakLine); 1434 1435 assert(root.value == "hello"); 1436 1437 } 1438 1439 unittest { 1440 1441 auto root = textInput(.multiline); 1442 1443 root.push("hello"); 1444 root.runInputAction!(FluidInputAction.breakLine); 1445 assert(root.value == "hello\n"); 1446 1447 root.undo(); 1448 assert(root.value == "hello"); 1449 root.redo(); 1450 assert(root.value == "hello\n"); 1451 1452 root.undo(); 1453 assert(root.value == "hello"); 1454 root.undo(); 1455 assert(root.value == ""); 1456 root.redo(); 1457 assert(root.value == "hello"); 1458 root.redo(); 1459 assert(root.value == "hello\n"); 1460 1461 } 1462 1463 unittest { 1464 1465 auto root = textInput(.nullTheme, .multiline); 1466 1467 root.push("Привет, мир!"); 1468 root.runInputAction!(FluidInputAction.breakLine); 1469 1470 assert(root.value == "Привет, мир!\n"); 1471 assert(root.caretIndex == root.value.length); 1472 1473 root.push("Это пример текста для тестирования поддержки Unicode во Fluid."); 1474 root.runInputAction!(FluidInputAction.breakLine); 1475 1476 assert(root.value == "Привет, мир!\nЭто пример текста для тестирования поддержки Unicode во Fluid.\n"); 1477 assert(root.caretIndex == root.value.length); 1478 1479 } 1480 1481 unittest { 1482 1483 auto root = textInput(.multiline); 1484 root.push("first line"); 1485 root.breakLine(); 1486 root.push("second line"); 1487 root.breakLine(); 1488 assert(root.value == "first line\nsecond line\n"); 1489 1490 root.undo(); 1491 assert(root.value == "first line\nsecond line"); 1492 root.undo(); 1493 assert(root.value == "first line\n"); 1494 root.undo(); 1495 assert(root.value == "first line"); 1496 root.undo(); 1497 assert(root.value == ""); 1498 root.redo(); 1499 assert(root.value == "first line"); 1500 root.redo(); 1501 assert(root.value == "first line\n"); 1502 root.redo(); 1503 assert(root.value == "first line\nsecond line"); 1504 root.redo(); 1505 assert(root.value == "first line\nsecond line\n"); 1506 1507 } 1508 1509 /// Submit the input. 1510 @(FluidInputAction.submit) 1511 void submit() { 1512 1513 import std.sumtype : match; 1514 1515 // breakLine has higher priority, stop if it's active 1516 if (multiline && tree.isActive!(FluidInputAction.breakLine)) return; 1517 1518 // Run the callback 1519 if (submitted) submitted(); 1520 1521 } 1522 1523 unittest { 1524 1525 int submitted; 1526 1527 auto io = new HeadlessBackend; 1528 TextInput root; 1529 1530 root = textInput("placeholder", delegate { 1531 submitted++; 1532 assert(root.value == "Hello World"); 1533 }); 1534 1535 root.io = io; 1536 1537 // Type stuff 1538 { 1539 root.value = "Hello World"; 1540 root.focus(); 1541 root.updateSize(); 1542 root.draw(); 1543 1544 assert(submitted == 0); 1545 assert(root.value == "Hello World"); 1546 assert(root.contentLabel.text == "Hello World"); 1547 } 1548 1549 // Submit 1550 { 1551 io.press(KeyboardKey.enter); 1552 root.draw(); 1553 1554 assert(submitted == 1); 1555 } 1556 1557 } 1558 1559 unittest { 1560 1561 int submitted; 1562 auto io = new HeadlessBackend; 1563 auto root = textInput( 1564 .multiline, 1565 "", 1566 delegate { 1567 submitted++; 1568 } 1569 ); 1570 1571 root.io = io; 1572 root.push("Hello, World!"); 1573 1574 // Press enter (not focused) 1575 io.press(KeyboardKey.enter); 1576 root.draw(); 1577 1578 // No effect 1579 assert(root.value == "Hello, World!"); 1580 assert(submitted == 0); 1581 1582 // Focus for the next frame 1583 io.nextFrame(); 1584 root.focus(); 1585 1586 // Press enter 1587 io.press(KeyboardKey.enter); 1588 root.draw(); 1589 1590 // A new line should be added 1591 assert(root.value == "Hello, World!\n"); 1592 assert(submitted == 0); 1593 1594 // Press ctrl+enter 1595 io.nextFrame(); 1596 version (OSX) 1597 io.press(KeyboardKey.leftCommand); 1598 else 1599 io.press(KeyboardKey.leftControl); 1600 io.press(KeyboardKey.enter); 1601 root.draw(); 1602 1603 // Input should be submitted 1604 assert(root.value == "Hello, World!\n"); 1605 assert(submitted == 1); 1606 1607 } 1608 1609 /// Erase last word before the caret, or the first word after. 1610 /// 1611 /// Parms: 1612 /// forward = If true, delete the next word. If false, delete the previous. 1613 void chopWord(bool forward = false) { 1614 1615 import std.uni; 1616 import std.range; 1617 1618 // Save previous value in undo stack 1619 const previousState = snapshot(); 1620 scope (success) pushSnapshot(previousState); 1621 1622 // Selection active, delete it 1623 if (isSelecting) { 1624 1625 selectedValue = null; 1626 1627 } 1628 1629 // Remove next word 1630 else if (forward) { 1631 1632 // Find the word to delete 1633 const erasedWord = valueAfterCaret.wordFront; 1634 1635 // Remove the word 1636 valueAfterCaret = valueAfterCaret[erasedWord.length .. $]; 1637 1638 } 1639 1640 // Remove previous word 1641 else { 1642 1643 // Find the word to delete 1644 const erasedWord = valueBeforeCaret.wordBack; 1645 1646 // Remove the word 1647 valueBeforeCaret = valueBeforeCaret[0 .. $ - erasedWord.length]; 1648 1649 } 1650 1651 // Update the size of the box 1652 updateSize(); 1653 updateCaretPosition(); 1654 horizontalAnchor = caretPosition.x; 1655 1656 } 1657 1658 unittest { 1659 1660 auto root = textInput(); 1661 1662 root.push("Это пример текста для тестирования поддержки Unicode во Fluid."); 1663 root.chopWord; 1664 assert(root.value == "Это пример текста для тестирования поддержки Unicode во Fluid"); 1665 1666 root.chopWord; 1667 assert(root.value == "Это пример текста для тестирования поддержки Unicode во "); 1668 1669 root.chopWord; 1670 assert(root.value == "Это пример текста для тестирования поддержки Unicode "); 1671 1672 root.chopWord; 1673 assert(root.value == "Это пример текста для тестирования поддержки "); 1674 1675 root.chopWord; 1676 assert(root.value == "Это пример текста для тестирования "); 1677 1678 root.caretToStart(); 1679 root.chopWord(true); 1680 assert(root.value == "пример текста для тестирования "); 1681 1682 root.chopWord(true); 1683 assert(root.value == "текста для тестирования "); 1684 1685 } 1686 1687 /// Remove a word before the caret. 1688 @(FluidInputAction.backspaceWord) 1689 void backspaceWord() { 1690 1691 chopWord(); 1692 touchText(); 1693 1694 } 1695 1696 unittest { 1697 1698 auto io = new HeadlessBackend; 1699 auto root = textInput(); 1700 1701 root.io = io; 1702 1703 // Type stuff 1704 { 1705 root.value = "Hello World"; 1706 root.focus(); 1707 root.caretToEnd(); 1708 root.draw(); 1709 1710 assert(root.value == "Hello World"); 1711 assert(root.contentLabel.text == "Hello World"); 1712 } 1713 1714 // Erase a word 1715 { 1716 io.nextFrame; 1717 root.chopWord; 1718 root.draw(); 1719 1720 assert(root.value == "Hello "); 1721 assert(root.contentLabel.text == "Hello "); 1722 assert(root.isFocused); 1723 } 1724 1725 // Erase a word 1726 { 1727 io.nextFrame; 1728 root.chopWord; 1729 root.draw(); 1730 1731 assert(root.value == ""); 1732 assert(root.contentLabel.text == ""); 1733 assert(root.isEmpty); 1734 } 1735 1736 // Typing should be disabled while erasing 1737 { 1738 io.press(KeyboardKey.leftControl); 1739 io.press(KeyboardKey.w); 1740 io.inputCharacter('w'); 1741 1742 root.draw(); 1743 1744 assert(root.value == ""); 1745 assert(root.contentLabel.text == ""); 1746 assert(root.isEmpty); 1747 } 1748 1749 } 1750 1751 /// Delete a word in front of the caret. 1752 @(FluidInputAction.deleteWord) 1753 void deleteWord() { 1754 1755 chopWord(true); 1756 touchText(); 1757 1758 } 1759 1760 unittest { 1761 1762 auto root = textInput(); 1763 1764 // deleteWord should do nothing, because the caret is at the end 1765 root.push("Hello, Wörld"); 1766 root.runInputAction!(FluidInputAction.deleteWord); 1767 1768 assert(!root.isSelecting); 1769 assert(root.value == "Hello, Wörld"); 1770 assert(root.caretIndex == "Hello, Wörld".length); 1771 1772 // Move it to the previous word 1773 root.runInputAction!(FluidInputAction.previousWord); 1774 1775 assert(!root.isSelecting); 1776 assert(root.value == "Hello, Wörld"); 1777 assert(root.caretIndex == "Hello, ".length); 1778 1779 // Delete the next word 1780 root.runInputAction!(FluidInputAction.deleteWord); 1781 1782 assert(!root.isSelecting); 1783 assert(root.value == "Hello, "); 1784 assert(root.caretIndex == "Hello, ".length); 1785 1786 // Move to the start 1787 root.runInputAction!(FluidInputAction.toStart); 1788 1789 assert(!root.isSelecting); 1790 assert(root.value == "Hello, "); 1791 assert(root.caretIndex == 0); 1792 1793 // Delete the next word 1794 root.runInputAction!(FluidInputAction.deleteWord); 1795 1796 assert(!root.isSelecting); 1797 assert(root.value == ", "); 1798 assert(root.caretIndex == 0); 1799 1800 // Delete the next word 1801 root.runInputAction!(FluidInputAction.deleteWord); 1802 1803 assert(!root.isSelecting); 1804 assert(root.value == ""); 1805 assert(root.caretIndex == 0); 1806 1807 } 1808 1809 /// Erase any character preceding the caret, or the next one. 1810 /// Params: 1811 /// forward = If true, removes character after the caret, otherwise removes the one before. 1812 void chop(bool forward = false) { 1813 1814 // Save previous value in undo stack 1815 const previousState = snapshot(); 1816 scope (success) pushSnapshot(previousState); 1817 1818 // Selection active 1819 if (isSelecting) { 1820 1821 selectedValue = null; 1822 1823 } 1824 1825 // Remove next character 1826 else if (forward) { 1827 1828 if (valueAfterCaret == "") return; 1829 1830 const length = valueAfterCaret.decodeFrontStatic.codeLength!char; 1831 1832 valueAfterCaret = valueAfterCaret[length..$]; 1833 1834 } 1835 1836 // Remove previous character 1837 else { 1838 1839 if (valueBeforeCaret == "") return; 1840 1841 const length = valueBeforeCaret.decodeBackStatic.codeLength!char; 1842 1843 valueBeforeCaret = valueBeforeCaret[0..$-length]; 1844 1845 } 1846 1847 // Trigger the callback 1848 touchText(); 1849 1850 // Update the size of the box 1851 updateSize(); 1852 updateCaretPosition(); 1853 horizontalAnchor = caretPosition.x; 1854 1855 } 1856 1857 unittest { 1858 1859 auto root = textInput(); 1860 1861 root.push("поддержки во Fluid."); 1862 root.chop; 1863 assert(root.value == "поддержки во Fluid"); 1864 1865 root.chop; 1866 assert(root.value == "поддержки во Flui"); 1867 1868 root.chop; 1869 assert(root.value == "поддержки во Flu"); 1870 1871 root.chopWord; 1872 assert(root.value == "поддержки во "); 1873 1874 root.chop; 1875 assert(root.value == "поддержки во"); 1876 1877 root.chop; 1878 assert(root.value == "поддержки в"); 1879 1880 root.chop; 1881 assert(root.value == "поддержки "); 1882 1883 root.caretToStart(); 1884 root.chop(true); 1885 assert(root.value == "оддержки "); 1886 1887 root.chop(true); 1888 assert(root.value == "ддержки "); 1889 1890 root.chop(true); 1891 assert(root.value == "держки "); 1892 1893 } 1894 1895 private { 1896 1897 bool _pressed; 1898 1899 /// Number of clicks performed within short enough time from each other. First click is number 0. 1900 int _clickCount; 1901 1902 /// Time of the last `press` event, used to enable double click and triple click selection. 1903 SysTime _lastClick; 1904 1905 /// Position of the last click. 1906 Vector2 _lastClickPosition; 1907 1908 } 1909 1910 protected override void mouseImpl() { 1911 1912 enum maxDistance = 5; 1913 1914 // Pressing with the mouse 1915 if (!tree.isMouseDown!(FluidInputAction.press)) return; 1916 1917 const justPressed = !_pressed; 1918 1919 // Just pressed 1920 if (justPressed) { 1921 1922 const clickPosition = io.mousePosition; 1923 1924 // To count as repeated, the click must be within the specified double click time, and close enough 1925 // to the original location 1926 const isRepeated = Clock.currTime - _lastClick < io.doubleClickTime 1927 && distance(clickPosition, _lastClickPosition) < maxDistance; 1928 1929 // Count repeated clicks 1930 _clickCount = isRepeated 1931 ? _clickCount + 1 1932 : 0; 1933 1934 // Register the click 1935 _lastClick = Clock.currTime; 1936 _lastClickPosition = clickPosition; 1937 1938 } 1939 1940 // Move the caret with the mouse 1941 caretToMouse(); 1942 moveOrClearSelection(); 1943 1944 final switch (_clickCount % 3) { 1945 1946 // First click, merely move the caret while selecting 1947 case 0: break; 1948 1949 // Second click, select the word surrounding the caret 1950 case 1: 1951 selectWord(); 1952 break; 1953 1954 // Third click, select whole line 1955 case 2: 1956 selectLine(); 1957 break; 1958 1959 } 1960 1961 // Enable selection mode 1962 // Disable it when releasing 1963 _pressed = selectionMovement = !tree.isMouseActive!(FluidInputAction.press); 1964 1965 } 1966 1967 unittest { 1968 1969 // This test relies on properties of the default typeface 1970 1971 import std.math : isClose; 1972 1973 auto io = new HeadlessBackend; 1974 auto root = textInput(nullTheme.derive( 1975 rule!TextInput( 1976 Rule.selectionBackgroundColor = color("#02a"), 1977 ), 1978 )); 1979 1980 root.io = io; 1981 root.value = "Hello, World! Foo, bar, scroll this input"; 1982 root.focus(); 1983 root.caretToEnd(); 1984 root.draw(); 1985 1986 assert(root.scroll.isClose(127)); 1987 1988 // Select some stuff 1989 io.nextFrame; 1990 io.mousePosition = Vector2(150, 10); 1991 io.press(); 1992 root.draw(); 1993 1994 io.nextFrame; 1995 io.mousePosition = Vector2(65, 10); 1996 root.draw(); 1997 1998 assert(root.selectedValue == "scroll this"); 1999 2000 io.nextFrame; 2001 root.draw(); 2002 2003 // Match the selection box 2004 io.assertRectangle( 2005 Rectangle(64, 0, 86, 27), 2006 color("#02a") 2007 ); 2008 2009 } 2010 2011 unittest { 2012 2013 // This test relies on properties of the default typeface 2014 2015 import std.math : isClose; 2016 2017 auto io = new HeadlessBackend; 2018 auto root = textInput(nullTheme.derive( 2019 rule!TextInput( 2020 Rule.selectionBackgroundColor = color("#02a"), 2021 ), 2022 )); 2023 2024 root.io = io; 2025 root.value = "Hello, World! Foo, bar, scroll this input"; 2026 root.focus(); 2027 root.caretToEnd(); 2028 root.draw(); 2029 2030 io.mousePosition = Vector2(150, 10); 2031 2032 // Double- and triple-click 2033 foreach (i; 0..3) { 2034 2035 io.nextFrame; 2036 io.press(); 2037 root.draw(); 2038 2039 io.nextFrame; 2040 io.release(); 2041 root.draw(); 2042 2043 // Double-clicked 2044 if (i == 1) { 2045 assert(root.selectedValue == "this"); 2046 } 2047 2048 // Triple-clicked 2049 if (i == 2) { 2050 assert(root.selectedValue == root.value); 2051 } 2052 2053 } 2054 2055 io.nextFrame; 2056 root.draw(); 2057 2058 } 2059 2060 unittest { 2061 2062 import std.math : isClose; 2063 2064 auto io = new HeadlessBackend; 2065 auto theme = nullTheme.derive( 2066 rule!TextInput( 2067 Rule.selectionBackgroundColor = color("#02a"), 2068 ), 2069 ); 2070 auto root = textInput(.multiline, theme); 2071 auto lineHeight = root.style.getTypeface.lineHeight; 2072 2073 root.io = io; 2074 root.value = "Line one\nLine two\n\nLine four"; 2075 root.focus(); 2076 root.draw(); 2077 2078 // Move the caret to second line 2079 root.caretIndex = "Line one\nLin".length; 2080 root.updateCaretPosition(); 2081 2082 const middle = root._inner.start + root.caretPosition; 2083 const top = middle - Vector2(0, lineHeight); 2084 const blank = middle + Vector2(0, lineHeight); 2085 const bottom = middle + Vector2(0, lineHeight * 2); 2086 2087 { 2088 2089 // Press, and move the mouse around 2090 io.nextFrame(); 2091 io.mousePosition = middle; 2092 io.press(); 2093 root.draw(); 2094 2095 // Move it to top row 2096 io.nextFrame(); 2097 io.mousePosition = top; 2098 root.draw(); 2099 2100 assert(root.selectedValue == "e one\nLin"); 2101 assert(root.selectionStart > root.selectionEnd); 2102 2103 // Move it to bottom row 2104 io.nextFrame(); 2105 io.mousePosition = bottom; 2106 root.draw(); 2107 2108 assert(root.selectedValue == "e two\n\nLin"); 2109 assert(root.selectionStart < root.selectionEnd); 2110 2111 // And to the blank line 2112 io.nextFrame(); 2113 io.mousePosition = blank; 2114 root.draw(); 2115 2116 assert(root.selectedValue == "e two\n"); 2117 assert(root.selectionStart < root.selectionEnd); 2118 2119 } 2120 2121 { 2122 2123 // Double click 2124 io.mousePosition = middle; 2125 root._lastClick = SysTime.init; 2126 2127 foreach (i; 0..2) { 2128 2129 io.nextFrame(); 2130 io.release(); 2131 root.draw(); 2132 2133 io.nextFrame(); 2134 io.press(); 2135 root.draw(); 2136 2137 } 2138 2139 assert(root.selectedValue == "Line"); 2140 assert(root.selectionStart < root.selectionEnd); 2141 2142 // Move it to top row 2143 io.nextFrame(); 2144 io.mousePosition = top; 2145 root.draw(); 2146 2147 assert(root.selectedValue == "Line one\nLine"); 2148 assert(root.selectionStart > root.selectionEnd); 2149 2150 // Move it to bottom row 2151 io.nextFrame(); 2152 io.mousePosition = bottom; 2153 root.draw(); 2154 2155 assert(root.selectedValue == "Line two\n\nLine"); 2156 assert(root.selectionStart < root.selectionEnd); 2157 2158 // And to the blank line 2159 io.nextFrame(); 2160 io.mousePosition = blank; 2161 root.draw(); 2162 2163 assert(root.selectedValue == "Line two\n"); 2164 assert(root.selectionStart < root.selectionEnd); 2165 2166 } 2167 2168 { 2169 2170 // Triple 2171 io.mousePosition = middle; 2172 root._lastClick = SysTime.init; 2173 2174 foreach (i; 0..3) { 2175 2176 io.nextFrame(); 2177 io.release(); 2178 root.draw(); 2179 2180 io.nextFrame(); 2181 io.press(); 2182 root.draw(); 2183 2184 } 2185 2186 assert(root.selectedValue == "Line two"); 2187 assert(root.selectionStart < root.selectionEnd); 2188 2189 // Move it to top row 2190 io.nextFrame(); 2191 io.mousePosition = top; 2192 root.draw(); 2193 2194 assert(root.selectedValue == "Line one\nLine two"); 2195 assert(root.selectionStart > root.selectionEnd); 2196 2197 // Move it to bottom row 2198 io.nextFrame(); 2199 io.mousePosition = bottom; 2200 root.draw(); 2201 2202 assert(root.selectedValue == "Line two\n\nLine four"); 2203 assert(root.selectionStart < root.selectionEnd); 2204 2205 // And to the blank line 2206 io.nextFrame(); 2207 io.mousePosition = blank; 2208 root.draw(); 2209 2210 assert(root.selectedValue == "Line two\n"); 2211 assert(root.selectionStart < root.selectionEnd); 2212 2213 } 2214 2215 } 2216 2217 protected override void scrollImpl(Vector2 value) { 2218 2219 const speed = ScrollInput.scrollSpeed; 2220 const move = speed * value.x; 2221 2222 scroll = scroll + move; 2223 2224 } 2225 2226 Rectangle shallowScrollTo(const(Node) child, Rectangle parentBox, Rectangle childBox) { 2227 2228 return childBox; 2229 2230 } 2231 2232 /// Get line in the input by a byte index. 2233 /// Returns: A rope slice with the line containing the given index. 2234 Rope lineByIndex(KeepTerminator keepTerminator = No.keepTerminator)(size_t index) const { 2235 2236 return value.lineByIndex!keepTerminator(index); 2237 2238 } 2239 2240 /// Update a line with given byte index. 2241 const(char)[] lineByIndex(size_t index, const(char)[] value) { 2242 2243 lineByIndex(index, Rope(value)); 2244 return value; 2245 2246 } 2247 2248 /// ditto 2249 Rope lineByIndex(size_t index, Rope newValue) { 2250 2251 import std.utf; 2252 import fluid.typeface; 2253 2254 const backLength = Typeface.lineSplitter(value[0..index].retro).front.byChar.walkLength; 2255 const frontLength = Typeface.lineSplitter(value[index..$]).front.byChar.walkLength; 2256 const start = index - backLength; 2257 const end = index + frontLength; 2258 size_t[2] selection = [selectionStart, selectionEnd]; 2259 2260 // Combine everything on the same line, before and after the caret 2261 value = value.replace(start, end, newValue); 2262 2263 // Caret/selection needs updating 2264 foreach (i, ref caret; selection) { 2265 2266 const diff = newValue.length - end + start; 2267 2268 if (caret < start) continue; 2269 2270 // Move the caret to the start or end, depending on its type 2271 if (caret <= end) { 2272 2273 // Selection start moves to the beginning 2274 // But only if selection is active 2275 if (i == 0 && isSelecting) 2276 caret = start; 2277 2278 // End moves to the end 2279 else 2280 caret = start + newValue.length; 2281 2282 } 2283 2284 // Offset the caret 2285 else caret += diff; 2286 2287 } 2288 2289 // Update the carets 2290 selectionStart = selection[0]; 2291 selectionEnd = selection[1]; 2292 2293 return newValue; 2294 2295 } 2296 2297 unittest { 2298 2299 auto root = textInput(.multiline); 2300 root.push("foo"); 2301 root.lineByIndex(0, "foobar"); 2302 assert(root.value == "foobar"); 2303 assert(root.valueBeforeCaret == "foobar"); 2304 2305 root.push("\nąąąźź"); 2306 root.lineByIndex(6, "~"); 2307 root.caretIndex = root.caretIndex - 2; 2308 assert(root.value == "~\nąąąźź"); 2309 assert(root.valueBeforeCaret == "~\nąąąź"); 2310 2311 root.push("\n\nstuff"); 2312 assert(root.value == "~\nąąąź\n\nstuffź"); 2313 2314 root.lineByIndex(11, ""); 2315 assert(root.value == "~\nąąąź\n\nstuffź"); 2316 2317 root.lineByIndex(11, "*"); 2318 assert(root.value == "~\nąąąź\n*\nstuffź"); 2319 2320 } 2321 2322 unittest { 2323 2324 auto root = textInput(.multiline); 2325 root.push("óne\nßwo\nßhree"); 2326 root.selectionStart = 5; 2327 root.selectionEnd = 14; 2328 root.lineByIndex(5, "[REDACTED]"); 2329 assert(root.value[root.selectionEnd] == 'e'); 2330 assert(root.value == "óne\n[REDACTED]\nßhree"); 2331 2332 assert(root.value[root.selectionEnd] == 'e'); 2333 assert(root.selectionStart == 5); 2334 assert(root.selectionEnd == 20); 2335 2336 } 2337 2338 /// Get the index of the start or end of the line — from index of any character on the same line. 2339 size_t lineStartByIndex(size_t index) { 2340 2341 return value.lineStartByIndex(index); 2342 2343 } 2344 2345 /// ditto 2346 size_t lineEndByIndex(size_t index) { 2347 2348 return value.lineEndByIndex(index); 2349 2350 } 2351 2352 /// Get the current line 2353 Rope caretLine() { 2354 2355 return value.lineByIndex(caretIndex); 2356 2357 } 2358 2359 unittest { 2360 2361 auto root = textInput(.multiline); 2362 assert(root.caretLine == ""); 2363 root.push("aąaa"); 2364 assert(root.caretLine == root.value); 2365 root.caretIndex = 0; 2366 assert(root.caretLine == root.value); 2367 root.push("bbb"); 2368 assert(root.caretLine == root.value); 2369 assert(root.value == "bbbaąaa"); 2370 root.push("\n"); 2371 assert(root.value == "bbb\naąaa"); 2372 assert(root.caretLine == "aąaa"); 2373 root.caretToEnd(); 2374 root.push("xx"); 2375 assert(root.caretLine == "aąaaxx"); 2376 root.push("\n"); 2377 assert(root.caretLine == ""); 2378 root.push("\n"); 2379 assert(root.caretLine == ""); 2380 root.caretIndex = root.caretIndex - 1; 2381 assert(root.caretLine == ""); 2382 root.caretToStart(); 2383 assert(root.caretLine == "bbb"); 2384 2385 } 2386 2387 /// Change the current line. Moves the cursor to the end of the newly created line. 2388 const(char)[] caretLine(const(char)[] newValue) { 2389 2390 return lineByIndex(caretIndex, newValue); 2391 2392 } 2393 2394 /// ditto 2395 Rope caretLine(Rope newValue) { 2396 2397 return lineByIndex(caretIndex, newValue); 2398 2399 } 2400 2401 unittest { 2402 2403 auto root = textInput(.multiline); 2404 root.push("a\nbb\nccc\n"); 2405 assert(root.caretLine == ""); 2406 2407 root.caretIndex = root.caretIndex - 1; 2408 assert(root.caretLine == "ccc"); 2409 2410 root.caretLine = "hi"; 2411 assert(root.value == "a\nbb\nhi\n"); 2412 2413 assert(!root.isSelecting); 2414 assert(root.valueBeforeCaret == "a\nbb\nhi"); 2415 2416 root.caretLine = ""; 2417 assert(root.value == "a\nbb\n\n"); 2418 assert(root.valueBeforeCaret == "a\nbb\n"); 2419 2420 root.caretLine = "new value"; 2421 assert(root.value == "a\nbb\nnew value\n"); 2422 assert(root.valueBeforeCaret == "a\nbb\nnew value"); 2423 2424 root.caretIndex = 0; 2425 root.caretLine = "insert"; 2426 assert(root.value == "insert\nbb\nnew value\n"); 2427 assert(root.valueBeforeCaret == "insert"); 2428 assert(root.caretLine == "insert"); 2429 2430 } 2431 2432 /// Get the column the given index (or the cursor, if omitted) is on. 2433 /// Returns: 2434 /// Return value depends on the type fed into the function. `column!dchar` will use characters and `column!char` 2435 /// will use bytes. The type does not have effect on the input index. 2436 ptrdiff_t column(Chartype)(ptrdiff_t index) { 2437 2438 return value.column!Chartype(index); 2439 2440 } 2441 2442 /// ditto 2443 ptrdiff_t column(Chartype)() { 2444 2445 return column!Chartype(caretIndex); 2446 2447 } 2448 2449 unittest { 2450 2451 auto root = textInput(.multiline); 2452 assert(root.column!dchar == 0); 2453 root.push(" "); 2454 assert(root.column!dchar == 1); 2455 root.push("a"); 2456 assert(root.column!dchar == 2); 2457 root.push("ąąą"); 2458 assert(root.column!dchar == 5); 2459 assert(root.column!char == 8); 2460 root.push("O\n"); 2461 assert(root.column!dchar == 0); 2462 root.push(" "); 2463 assert(root.column!dchar == 1); 2464 root.push("HHH"); 2465 assert(root.column!dchar == 4); 2466 2467 } 2468 2469 /// Iterate on each line in an interval. 2470 auto eachLineByIndex(ptrdiff_t start, ptrdiff_t end) { 2471 2472 struct LineIterator { 2473 2474 TextInput input; 2475 ptrdiff_t index; 2476 ptrdiff_t end; 2477 2478 private Rope front; 2479 private ptrdiff_t nextLine; 2480 2481 alias SetLine = void delegate(Rope line) @safe; 2482 2483 int opApply(scope int delegate(size_t startIndex, ref Rope line) @safe yield) { 2484 2485 while (index <= end) { 2486 2487 const line = input.value.lineByIndex!(Yes.keepTerminator)(index); 2488 2489 // Get index of the next line 2490 const lineStart = index - input.column!char(index); 2491 nextLine = lineStart + line.length; 2492 2493 // Output the line 2494 const originalFront = front = line[].chomp; 2495 auto stop = yield(lineStart, front); 2496 2497 // Update indices in case the line has changed 2498 if (front !is originalFront) { 2499 setLine(originalFront, front); 2500 } 2501 2502 // Stop if requested 2503 if (stop) return stop; 2504 2505 // Stop if reached the end of string 2506 if (index == nextLine) return 0; 2507 if (line.length == originalFront.length) return 0; 2508 2509 // Move to the next line 2510 index = nextLine; 2511 2512 } 2513 2514 return 0; 2515 2516 } 2517 2518 int opApply(scope int delegate(ref Rope line) @safe yield) { 2519 2520 foreach (index, ref line; this) { 2521 2522 if (auto stop = yield(line)) return stop; 2523 2524 } 2525 2526 return 0; 2527 2528 } 2529 2530 /// Replace the current line with a new one. 2531 private void setLine(Rope oldLine, Rope line) @safe { 2532 2533 const lineStart = index - input.column!char(index); 2534 2535 // Get the size of the line terminator 2536 const lineTerminatorLength = nextLine - lineStart - oldLine.length; 2537 2538 // Update the line 2539 input.lineByIndex(index, line); 2540 index = lineStart + line.length; 2541 end += line.length - oldLine.length; 2542 2543 // Add the terminator 2544 nextLine = index + lineTerminatorLength; 2545 2546 assert(line == front); 2547 assert(nextLine >= index); 2548 assert(nextLine <= input.value.length); 2549 2550 } 2551 2552 } 2553 2554 return LineIterator(this, start, end); 2555 2556 } 2557 2558 unittest { 2559 2560 auto root = textInput(.multiline); 2561 root.push("aaaąąą@\r\n#\n##ąąśðą\nĄŚ®ŒĘ¥Ę®\n"); 2562 2563 size_t i; 2564 foreach (line; root.eachLineByIndex(4, 18)) { 2565 2566 if (i == 0) assert(line == "aaaąąą@"); 2567 if (i == 1) assert(line == "#"); 2568 if (i == 2) assert(line == "##ąąśðą"); 2569 assert(i.among(0, 1, 2)); 2570 i++; 2571 2572 } 2573 assert(i == 3); 2574 2575 i = 0; 2576 foreach (line; root.eachLineByIndex(22, 27)) { 2577 2578 if (i == 0) assert(line == "##ąąśðą"); 2579 if (i == 1) assert(line == "ĄŚ®ŒĘ¥Ę®"); 2580 assert(i.among(0, 1)); 2581 i++; 2582 2583 } 2584 assert(i == 2); 2585 2586 i = 0; 2587 foreach (line; root.eachLineByIndex(44, 44)) { 2588 2589 assert(i == 0); 2590 assert(line == ""); 2591 i++; 2592 2593 } 2594 assert(i == 1); 2595 2596 i = 0; 2597 foreach (line; root.eachLineByIndex(1, 1)) { 2598 2599 assert(i == 0); 2600 assert(line == "aaaąąą@"); 2601 i++; 2602 2603 } 2604 assert(i == 1); 2605 2606 } 2607 2608 unittest { 2609 2610 auto root = textInput(.multiline); 2611 root.push("skip\nonë\r\ntwo\r\nthree\n"); 2612 2613 assert(root.lineByIndex(4) == "skip"); 2614 assert(root.lineByIndex(8) == "onë"); 2615 assert(root.lineByIndex(12) == "two"); 2616 2617 size_t i; 2618 foreach (lineStart, ref line; root.eachLineByIndex(5, root.value.length)) { 2619 2620 if (i == 0) { 2621 assert(line == "onë"); 2622 assert(lineStart == 5); 2623 line = Rope("value"); 2624 } 2625 else if (i == 1) { 2626 assert(root.value == "skip\nvalue\r\ntwo\r\nthree\n"); 2627 assert(lineStart == 12); 2628 assert(line == "two"); 2629 line = Rope("\nbar-bar-bar-bar-bar"); 2630 } 2631 else if (i == 2) { 2632 assert(root.value == "skip\nvalue\r\n\nbar-bar-bar-bar-bar\r\nthree\n"); 2633 assert(lineStart == 34); 2634 assert(line == "three"); 2635 line = Rope.init; 2636 } 2637 else if (i == 3) { 2638 assert(root.value == "skip\nvalue\r\n\nbar-bar-bar-bar-bar\r\n\n"); 2639 assert(lineStart == root.value.length); 2640 assert(line == ""); 2641 } 2642 else assert(false); 2643 2644 i++; 2645 2646 } 2647 2648 assert(i == 4); 2649 2650 } 2651 2652 unittest { 2653 2654 auto root = textInput(.multiline); 2655 root.push("Fïrst line\nSëcond line\r\n Third line\n Fourth line\rFifth line"); 2656 2657 size_t i = 0; 2658 foreach (ref line; root.eachLineByIndex(19, 49)) { 2659 2660 if (i == 0) assert(line == "Sëcond line"); 2661 else if (i == 1) assert(line == " Third line"); 2662 else if (i == 2) assert(line == " Fourth line"); 2663 else assert(false); 2664 i++; 2665 2666 line = " " ~ line; 2667 2668 } 2669 assert(i == 3); 2670 root.selectionStart = 19; 2671 root.selectionEnd = 49; 2672 2673 } 2674 2675 unittest { 2676 2677 auto root = textInput(); 2678 root.value = "some text, some line, some stuff\ntext"; 2679 2680 foreach (ref line; root.eachLineByIndex(root.value.length, root.value.length)) { 2681 2682 line = Rope(""); 2683 line = Rope("woo"); 2684 line = Rope("n"); 2685 line = Rope(" ąąą "); 2686 line = Rope(""); 2687 2688 } 2689 2690 assert(root.value == ""); 2691 2692 } 2693 2694 unittest { 2695 2696 auto root = textInput(); 2697 root.value = "test"; 2698 2699 { 2700 size_t i; 2701 foreach (line; root.eachLineByIndex(1, 4)) { 2702 2703 assert(i++ == 0); 2704 assert(line == "test"); 2705 2706 } 2707 } 2708 2709 { 2710 size_t i; 2711 foreach (ref line; root.eachLineByIndex(1, 4)) { 2712 2713 assert(i++ == 0); 2714 assert(line == "test"); 2715 line = "tested"; 2716 2717 } 2718 assert(root.value == "tested"); 2719 } 2720 2721 } 2722 2723 /// Return each line containing the selection. 2724 auto eachSelectedLine() { 2725 2726 return eachLineByIndex(selectionLowIndex, selectionHighIndex); 2727 2728 } 2729 2730 unittest { 2731 2732 auto root = textInput(); 2733 2734 foreach (ref line; root.eachSelectedLine) { 2735 2736 line = Rope("value"); 2737 2738 } 2739 2740 assert(root.value == "value"); 2741 2742 } 2743 2744 /// Open the input's context menu. 2745 @(FluidInputAction.contextMenu) 2746 void openContextMenu() { 2747 2748 // Move the caret 2749 if (!isSelecting) 2750 caretToMouse(); 2751 2752 // Spawn the popup 2753 tree.spawnPopup(contextMenu); 2754 2755 // Anchor to caret position 2756 contextMenu.anchor = _inner.start + caretPosition; 2757 2758 } 2759 2760 /// Remove a character before the caret. Same as `chop`. 2761 @(FluidInputAction.backspace) 2762 void backspace() { 2763 2764 chop(); 2765 2766 } 2767 2768 /// Delete one character in front of the cursor. 2769 @(FluidInputAction.deleteChar) 2770 void deleteChar() { 2771 2772 chop(true); 2773 2774 } 2775 2776 unittest { 2777 2778 auto io = new HeadlessBackend; 2779 auto root = textInput(); 2780 2781 root.io = io; 2782 2783 // Type stuff 2784 { 2785 root.value = "hello‽"; 2786 root.focus(); 2787 root.caretToEnd(); 2788 root.draw(); 2789 2790 assert(root.value == "hello‽"); 2791 assert(root.contentLabel.text == "hello‽"); 2792 } 2793 2794 // Erase a letter 2795 { 2796 io.nextFrame; 2797 root.chop; 2798 root.draw(); 2799 2800 assert(root.value == "hello"); 2801 assert(root.contentLabel.text == "hello"); 2802 assert(root.isFocused); 2803 } 2804 2805 // Erase a letter 2806 { 2807 io.nextFrame; 2808 root.chop; 2809 root.draw(); 2810 2811 assert(root.value == "hell"); 2812 assert(root.contentLabel.text == "hell"); 2813 assert(root.isFocused); 2814 } 2815 2816 // Typing should be disabled while erasing 2817 { 2818 io.press(KeyboardKey.backspace); 2819 io.inputCharacter("o, world"); 2820 2821 root.draw(); 2822 2823 assert(root.value == "hel"); 2824 assert(root.isFocused); 2825 } 2826 2827 } 2828 2829 /// Clear the value of this input field, making it empty. 2830 void clear() 2831 out(; isEmpty) 2832 do { 2833 2834 // Remove the value 2835 value = null; 2836 2837 clearSelection(); 2838 updateCaretPosition(); 2839 horizontalAnchor = caretPosition.x; 2840 2841 } 2842 2843 unittest { 2844 2845 auto io = new HeadlessBackend; 2846 auto root = textInput(); 2847 2848 io.inputCharacter("Hello, World!"); 2849 root.io = io; 2850 root.focus(); 2851 root.draw(); 2852 2853 auto value1 = root.value; 2854 2855 root.chop(); 2856 2857 assert(root.value == "Hello, World"); 2858 2859 auto value2 = root.value; 2860 root.chopWord(); 2861 2862 assert(root.value == "Hello, "); 2863 2864 auto value3 = root.value; 2865 root.clear(); 2866 2867 assert(root.value == ""); 2868 2869 } 2870 2871 unittest { 2872 2873 auto io = new HeadlessBackend; 2874 auto root = textInput(); 2875 2876 io.inputCharacter("Hello, World"); 2877 root.io = io; 2878 root.focus(); 2879 root.draw(); 2880 2881 auto value1 = root.value; 2882 2883 root.chopWord(); 2884 2885 assert(root.value == "Hello, "); 2886 2887 auto value2 = root.value; 2888 2889 root.push("Moon"); 2890 2891 assert(root.value == "Hello, Moon"); 2892 2893 auto value3 = root.value; 2894 2895 root.clear(); 2896 2897 assert(root.value == ""); 2898 2899 } 2900 2901 /// Select the word surrounding the cursor. If selection is active, expands selection to cover words. 2902 void selectWord() { 2903 2904 enum excludeWhite = true; 2905 2906 const isLow = selectionStart <= selectionEnd; 2907 const low = selectionLowIndex; 2908 const high = selectionHighIndex; 2909 2910 const head = value[0 .. low].wordBack(excludeWhite); 2911 const tail = value[high .. $].wordFront(excludeWhite); 2912 2913 // Move the caret to the end of the word 2914 caretIndex = high + tail.length; 2915 2916 // Set selection to the start of the word 2917 selectionStart = low - head.length; 2918 2919 // Swap them if order is reversed 2920 if (!isLow) swap(_selectionStart, _caretIndex); 2921 2922 touch(); 2923 updateCaretPosition(false); 2924 2925 } 2926 2927 unittest { 2928 2929 auto root = textInput(); 2930 root.push("Привет, мир! Это пример текста для тестирования поддержки Unicode во Fluid."); 2931 2932 // Select word the caret is touching 2933 root.selectWord(); 2934 assert(root.selectedValue == "."); 2935 2936 // Expand 2937 root.selectWord(); 2938 assert(root.selectedValue == "Fluid."); 2939 2940 // Go to start 2941 root.caretToStart(); 2942 assert(!root.isSelecting); 2943 assert(root.caretIndex == 0); 2944 assert(root.selectedValue == ""); 2945 2946 root.selectWord(); 2947 assert(root.selectedValue == "Привет"); 2948 2949 root.selectWord(); 2950 assert(root.selectedValue == "Привет,"); 2951 2952 root.selectWord(); 2953 assert(root.selectedValue == "Привет,"); 2954 2955 root.runInputAction!(FluidInputAction.nextChar); 2956 assert(root.caretIndex == 13); // Before space 2957 2958 root.runInputAction!(FluidInputAction.nextChar); // After space 2959 root.runInputAction!(FluidInputAction.nextChar); // Inside "мир" 2960 assert(!root.isSelecting); 2961 assert(root.caretIndex == 16); 2962 2963 root.selectWord(); 2964 assert(root.selectedValue == "мир"); 2965 2966 root.selectWord(); 2967 assert(root.selectedValue == "мир!"); 2968 2969 } 2970 2971 /// Select the whole line the cursor is. 2972 void selectLine() { 2973 2974 const isLow = selectionStart <= selectionEnd; 2975 2976 foreach (index, line; Typeface.lineSplitterIndex(value)) { 2977 2978 const lineStart = index; 2979 const lineEnd = index + line.length; 2980 2981 // Found selection start 2982 if (lineStart <= selectionStart && selectionStart <= lineEnd) { 2983 2984 selectionStart = isLow 2985 ? lineStart 2986 : lineEnd; 2987 2988 } 2989 2990 // Found selection end 2991 if (lineStart <= selectionEnd && selectionEnd <= lineEnd) { 2992 2993 selectionEnd = isLow 2994 ? lineEnd 2995 : lineStart; 2996 2997 2998 } 2999 3000 } 3001 3002 updateCaretPosition(false); 3003 horizontalAnchor = caretPosition.x; 3004 3005 } 3006 3007 unittest { 3008 3009 auto root = textInput(); 3010 3011 root.push("ąąąą ąąą ąąąąąąą ąą\nąąą ąąą"); 3012 assert(root.caretIndex == 49); 3013 3014 root.selectLine(); 3015 assert(root.selectedValue == root.value); 3016 assert(root.selectedValue.length == 49); 3017 assert(root.value.length == 49); 3018 3019 } 3020 3021 unittest { 3022 3023 auto root = textInput(.multiline); 3024 3025 root.push("ąąą ąąą ąąąąąąą ąą\nąąą ąąą"); 3026 root.draw(); 3027 assert(root.caretIndex == 47); 3028 3029 root.selectLine(); 3030 assert(root.selectedValue == "ąąą ąąą"); 3031 assert(root.selectionStart == 34); 3032 assert(root.selectionEnd == 47); 3033 3034 root.runInputAction!(FluidInputAction.selectPreviousLine); 3035 assert(root.selectionStart == 34); 3036 assert(root.selectionEnd == 13); 3037 assert(root.selectedValue == " ąąąąąąą ąą\n"); 3038 3039 root.selectLine(); 3040 assert(root.selectedValue == root.value); 3041 3042 } 3043 3044 /// Move caret to the previous or next character. 3045 @(FluidInputAction.previousChar, FluidInputAction.nextChar) 3046 protected void previousOrNextChar(FluidInputAction action) { 3047 3048 const forward = action == FluidInputAction.nextChar; 3049 3050 // Terminating selection 3051 if (isSelecting && !selectionMovement) { 3052 3053 // Move to either end of the selection 3054 caretIndex = forward 3055 ? selectionHighIndex 3056 : selectionLowIndex; 3057 clearSelection(); 3058 3059 } 3060 3061 // Move to next character 3062 else if (forward) { 3063 3064 if (valueAfterCaret == "") return; 3065 3066 const length = valueAfterCaret.decodeFrontStatic.codeLength!char; 3067 3068 caretIndex = caretIndex + length; 3069 3070 } 3071 3072 // Move to previous character 3073 else { 3074 3075 if (valueBeforeCaret == "") return; 3076 3077 const length = valueBeforeCaret.decodeBackStatic.codeLength!char; 3078 3079 caretIndex = caretIndex - length; 3080 3081 } 3082 3083 updateCaretPosition(true); 3084 horizontalAnchor = caretPosition.x; 3085 3086 } 3087 3088 unittest { 3089 3090 auto root = textInput(); 3091 root.push("Привет, мир! Это пример текста для тестирования поддержки Unicode во Fluid."); 3092 3093 assert(root.caretIndex == root.value.length); 3094 3095 root.runInputAction!(FluidInputAction.previousWord); 3096 assert(root.caretIndex == root.value.length - ".".length); 3097 3098 root.runInputAction!(FluidInputAction.previousWord); 3099 assert(root.caretIndex == root.value.length - "Fluid.".length); 3100 3101 root.runInputAction!(FluidInputAction.previousChar); 3102 assert(root.caretIndex == root.value.length - " Fluid.".length); 3103 3104 root.runInputAction!(FluidInputAction.previousChar); 3105 assert(root.caretIndex == root.value.length - "о Fluid.".length); 3106 3107 root.runInputAction!(FluidInputAction.previousChar); 3108 assert(root.caretIndex == root.value.length - "во Fluid.".length); 3109 3110 root.runInputAction!(FluidInputAction.previousWord); 3111 assert(root.caretIndex == root.value.length - "Unicode во Fluid.".length); 3112 3113 root.runInputAction!(FluidInputAction.previousWord); 3114 assert(root.caretIndex == root.value.length - "поддержки Unicode во Fluid.".length); 3115 3116 root.runInputAction!(FluidInputAction.nextChar); 3117 assert(root.caretIndex == root.value.length - "оддержки Unicode во Fluid.".length); 3118 3119 root.runInputAction!(FluidInputAction.nextWord); 3120 assert(root.caretIndex == root.value.length - "Unicode во Fluid.".length); 3121 3122 } 3123 3124 /// Move caret to the previous or next word. 3125 @(FluidInputAction.previousWord, FluidInputAction.nextWord) 3126 protected void previousOrNextWord(FluidInputAction action) { 3127 3128 // Previous word 3129 if (action == FluidInputAction.previousWord) { 3130 3131 caretIndex = caretIndex - valueBeforeCaret.wordBack.length; 3132 3133 } 3134 3135 // Next word 3136 else { 3137 3138 caretIndex = caretIndex + valueAfterCaret.wordFront.length; 3139 3140 } 3141 3142 updateCaretPosition(true); 3143 moveOrClearSelection(); 3144 horizontalAnchor = caretPosition.x; 3145 3146 } 3147 3148 /// Move the caret to the previous or next line. 3149 @(FluidInputAction.previousLine, FluidInputAction.nextLine) 3150 protected void previousOrNextLine(FluidInputAction action) { 3151 3152 auto typeface = style.getTypeface; 3153 auto search = Vector2(horizontalAnchor, caretPosition.y); 3154 3155 // Next line 3156 if (action == FluidInputAction.nextLine) { 3157 3158 search.y += typeface.lineHeight; 3159 3160 } 3161 3162 // Previous line 3163 else { 3164 3165 search.y -= typeface.lineHeight; 3166 3167 } 3168 3169 caretTo(search); 3170 updateCaretPosition(horizontalAnchor < 1); 3171 moveOrClearSelection(); 3172 3173 } 3174 3175 unittest { 3176 3177 auto root = textInput(.multiline); 3178 3179 // 5 en dashes, 3 then 4; starting at last line 3180 root.push("–––––\n–––\n––––"); 3181 root.draw(); 3182 3183 assert(root.caretIndex == root.value.length); 3184 3185 // From last line to second line — caret should be at its end 3186 root.runInputAction!(FluidInputAction.previousLine); 3187 assert(root.valueBeforeCaret == "–––––\n–––"); 3188 3189 // First line, move to 4th dash (same as third line) 3190 root.runInputAction!(FluidInputAction.previousLine); 3191 assert(root.valueBeforeCaret == "––––"); 3192 3193 // Next line — end 3194 root.runInputAction!(FluidInputAction.nextLine); 3195 assert(root.valueBeforeCaret == "–––––\n–––"); 3196 3197 // Update anchor to match second line 3198 root.runInputAction!(FluidInputAction.toLineEnd); 3199 assert(root.valueBeforeCaret == "–––––\n–––"); 3200 3201 // First line again, should be 3rd dash now (same as second line) 3202 root.runInputAction!(FluidInputAction.previousLine); 3203 assert(root.valueBeforeCaret == "–––"); 3204 3205 // Last line, 3rd dash too 3206 root.runInputAction!(FluidInputAction.nextLine); 3207 root.runInputAction!(FluidInputAction.nextLine); 3208 assert(root.valueBeforeCaret == "–––––\n–––\n–––"); 3209 3210 } 3211 3212 /// Move the caret to the given screen position (viewport space). 3213 /// Params: 3214 /// position = Position in the screen to move the cursor to. 3215 void caretTo(Vector2 position) { 3216 3217 caretIndex = nearestCharacter(position); 3218 3219 } 3220 3221 @("TextInput.caretTo works") 3222 unittest { 3223 3224 // Note: This test depends on parameters specific to the default typeface. 3225 3226 import std.math : isClose; 3227 3228 auto io = new HeadlessBackend; 3229 auto root = textInput(.nullTheme, .multiline); 3230 3231 root.io = io; 3232 root.size = Vector2(200, 0); 3233 root.value = "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, make it long enough to cross over"; 3234 root.draw(); 3235 3236 // Move the caret to different points on the canvas 3237 3238 // Left side of the second "l" in "Hello", first line 3239 root.caretTo(Vector2(30, 10)); 3240 assert(root.caretIndex == "Hel".length); 3241 3242 // Right side of the same "l" 3243 root.caretTo(Vector2(33, 10)); 3244 assert(root.caretIndex == "Hell".length); 3245 3246 // Comma, right side, close to the second line 3247 root.caretTo(Vector2(50, 24)); 3248 assert(root.caretIndex == "Hello,".length); 3249 3250 // End of the line, far right 3251 root.caretTo(Vector2(200, 10)); 3252 assert(root.caretIndex == "Hello, World!".length); 3253 3254 // Start of the next line 3255 root.caretTo(Vector2(0, 30)); 3256 assert(root.caretIndex == "Hello, World!\n".length); 3257 3258 // Space, right between "Hello," and "Moon" 3259 root.caretTo(Vector2(54, 40)); 3260 assert(root.caretIndex == "Hello, World!\nHello, ".length); 3261 3262 // Empty line 3263 root.caretTo(Vector2(54, 60)); 3264 assert(root.caretIndex == "Hello, World!\nHello, Moon\n".length); 3265 3266 // Beginning of the next line; left side of the "H" 3267 root.caretTo(Vector2(4, 85)); 3268 assert(root.caretIndex == "Hello, World!\nHello, Moon\n\n".length); 3269 3270 // Wrapped line, the bottom of letter "p" in "up" 3271 root.caretTo(Vector2(142, 128)); 3272 assert(root.caretIndex == "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp".length); 3273 3274 // End of line 3275 root.caretTo(Vector2(160, 128)); 3276 assert(root.caretIndex == "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, ".length); 3277 3278 // Beginning of the next line; result should be the same 3279 root.caretTo(Vector2(2, 148)); 3280 assert(root.caretIndex == "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, ".length); 3281 3282 // Just by the way, check if the caret position is correct 3283 root.updateCaretPosition(true); 3284 assert(root.caretPosition.x.isClose(0)); 3285 assert(root.caretPosition.y.isClose(135)); 3286 3287 root.updateCaretPosition(false); 3288 assert(root.caretPosition.x.isClose(153)); 3289 assert(root.caretPosition.y.isClose(108)); 3290 3291 // Try the same with the third line 3292 root.caretTo(Vector2(200, 148)); 3293 assert(root.caretIndex 3294 == "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, make it long enough ".length); 3295 root.caretTo(Vector2(2, 168)); 3296 assert(root.caretIndex 3297 == "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, make it long enough ".length); 3298 3299 } 3300 3301 /// Move the caret to mouse position. 3302 void caretToMouse() { 3303 3304 caretTo(io.mousePosition - _inner.start); 3305 updateCaretPosition(false); 3306 horizontalAnchor = caretPosition.x; 3307 3308 } 3309 3310 unittest { 3311 3312 import std.math : isClose; 3313 3314 // caretToMouse is a just a wrapper over caretTo, enabling mouse input 3315 // This test checks if it correctly maps mouse coordinates to internal coordinates 3316 3317 auto io = new HeadlessBackend; 3318 auto theme = nullTheme.derive( 3319 rule!TextInput( 3320 Rule.margin = 40, 3321 Rule.padding = 40, 3322 ) 3323 ); 3324 auto root = textInput(.multiline, theme); 3325 3326 root.io = io; 3327 root.size = Vector2(200, 0); 3328 root.value = "123\n456\n789"; 3329 root.draw(); 3330 3331 io.nextFrame(); 3332 io.mousePosition = Vector2(140, 90); 3333 root.caretToMouse(); 3334 3335 assert(root.caretIndex == 3); 3336 3337 } 3338 3339 /// Move the caret to the beginning of the line. This function perceives the line visually, so if the text wraps, it 3340 /// will go to the beginning of the visible line, instead of the hard line break. 3341 @(FluidInputAction.toLineStart) 3342 void caretToLineStart() { 3343 3344 const search = Vector2(0, caretPosition.y); 3345 3346 caretTo(search); 3347 updateCaretPosition(true); 3348 moveOrClearSelection(); 3349 horizontalAnchor = caretPosition.x; 3350 3351 } 3352 3353 /// Move the caret to the end of the line. 3354 @(FluidInputAction.toLineEnd) 3355 void caretToLineEnd() { 3356 3357 const search = Vector2(float.infinity, caretPosition.y); 3358 3359 caretTo(search); 3360 updateCaretPosition(false); 3361 moveOrClearSelection(); 3362 horizontalAnchor = caretPosition.x; 3363 3364 } 3365 3366 unittest { 3367 3368 // Note: This test depends on parameters specific to the default typeface. 3369 3370 import std.math : isClose; 3371 3372 auto io = new HeadlessBackend; 3373 auto root = textInput(.nullTheme, .multiline); 3374 3375 root.io = io; 3376 root.size = Vector2(200, 0); 3377 root.value = "Hello, World!\nHello, Moon\n\nHello, Sun\nWrap this line µp, make it long enough to cross over"; 3378 root.focus(); 3379 root.draw(); 3380 3381 root.caretIndex = 0; 3382 root.updateCaretPosition(); 3383 root.runInputAction!(FluidInputAction.toLineEnd); 3384 3385 assert(root.caretIndex == "Hello, World!".length); 3386 3387 // Move to the next line, should be at the end 3388 root.runInputAction!(FluidInputAction.nextLine); 3389 3390 assert(root.valueBeforeCaret.wordBack == "Moon"); 3391 assert(root.valueAfterCaret.wordFront == "\n"); 3392 3393 // Move to the blank line 3394 root.runInputAction!(FluidInputAction.nextLine); 3395 3396 const blankLine = root.caretIndex; 3397 assert(root.valueBeforeCaret.wordBack == "\n"); 3398 assert(root.valueAfterCaret.wordFront == "\n"); 3399 3400 // toLineEnd and toLineStart should have no effect 3401 root.runInputAction!(FluidInputAction.toLineStart); 3402 assert(root.caretIndex == blankLine); 3403 root.runInputAction!(FluidInputAction.toLineEnd); 3404 assert(root.caretIndex == blankLine); 3405 3406 // Next line again 3407 // The anchor has been reset to the beginning 3408 root.runInputAction!(FluidInputAction.nextLine); 3409 3410 assert(root.valueBeforeCaret.wordBack == "\n"); 3411 assert(root.valueAfterCaret.wordFront == "Hello"); 3412 3413 // Move to the very end 3414 root.runInputAction!(FluidInputAction.toEnd); 3415 3416 assert(root.valueBeforeCaret.wordBack == "over"); 3417 assert(root.valueAfterCaret.wordFront == ""); 3418 3419 // Move to start of the line 3420 root.runInputAction!(FluidInputAction.toLineStart); 3421 3422 assert(root.valueBeforeCaret.wordBack == "enough "); 3423 assert(root.valueAfterCaret.wordFront == "to "); 3424 assert(root.caretPosition.x.isClose(0)); 3425 3426 // Move to the previous line 3427 root.runInputAction!(FluidInputAction.previousLine); 3428 3429 assert(root.valueBeforeCaret.wordBack == ", "); 3430 assert(root.valueAfterCaret.wordFront == "make "); 3431 assert(root.caretPosition.x.isClose(0)); 3432 3433 // Move to its end — position should be the same as earlier, but the caret should be on the same line 3434 root.runInputAction!(FluidInputAction.toLineEnd); 3435 3436 assert(root.valueBeforeCaret.wordBack == "enough "); 3437 assert(root.valueAfterCaret.wordFront == "to "); 3438 assert(root.caretPosition.x.isClose(181)); 3439 3440 // Move to the previous line — again 3441 root.runInputAction!(FluidInputAction.previousLine); 3442 3443 assert(root.valueBeforeCaret.wordBack == ", "); 3444 assert(root.valueAfterCaret.wordFront == "make "); 3445 assert(root.caretPosition.x.isClose(153)); 3446 3447 } 3448 3449 /// Move the caret to the beginning of the input 3450 @(FluidInputAction.toStart) 3451 void caretToStart() { 3452 3453 caretIndex = 0; 3454 updateCaretPosition(true); 3455 moveOrClearSelection(); 3456 horizontalAnchor = caretPosition.x; 3457 3458 } 3459 3460 /// Move the caret to the end of the input 3461 @(FluidInputAction.toEnd) 3462 void caretToEnd() { 3463 3464 caretIndex = value.length; 3465 updateCaretPosition(false); 3466 moveOrClearSelection(); 3467 horizontalAnchor = caretPosition.x; 3468 3469 } 3470 3471 /// Select all text 3472 @(FluidInputAction.selectAll) 3473 void selectAll() { 3474 3475 selectionMovement = true; 3476 scope (exit) selectionMovement = false; 3477 3478 _selectionStart = 0; 3479 caretToEnd(); 3480 3481 } 3482 3483 unittest { 3484 3485 auto root = textInput(); 3486 3487 root.draw(); 3488 root.selectAll(); 3489 3490 assert(root.selectionStart == 0); 3491 assert(root.selectionEnd == 0); 3492 3493 root.push("foo bar "); 3494 3495 assert(!root.isSelecting); 3496 3497 root.push("baz"); 3498 3499 assert(root.value == "foo bar baz"); 3500 3501 auto value1 = root.value; 3502 3503 root.selectAll(); 3504 3505 assert(root.selectionStart == 0); 3506 assert(root.selectionEnd == root.value.length); 3507 3508 root.push("replaced"); 3509 3510 assert(root.value == "replaced"); 3511 3512 } 3513 3514 /// Begin or continue selection using given movement action. 3515 /// 3516 /// Use `selectionStart` and `selectionEnd` to define selection boundaries manually. 3517 @( 3518 FluidInputAction.selectPreviousChar, 3519 FluidInputAction.selectNextChar, 3520 FluidInputAction.selectPreviousWord, 3521 FluidInputAction.selectNextWord, 3522 FluidInputAction.selectPreviousLine, 3523 FluidInputAction.selectNextLine, 3524 FluidInputAction.selectToLineStart, 3525 FluidInputAction.selectToLineEnd, 3526 FluidInputAction.selectToStart, 3527 FluidInputAction.selectToEnd, 3528 ) 3529 protected void select(FluidInputAction action) { 3530 3531 selectionMovement = true; 3532 scope (exit) selectionMovement = false; 3533 3534 // Start selection 3535 if (!isSelecting) 3536 selectionStart = caretIndex; 3537 3538 with (FluidInputAction) switch (action) { 3539 case selectPreviousChar: 3540 runInputAction!previousChar; 3541 break; 3542 case selectNextChar: 3543 runInputAction!nextChar; 3544 break; 3545 case selectPreviousWord: 3546 runInputAction!previousWord; 3547 break; 3548 case selectNextWord: 3549 runInputAction!nextWord; 3550 break; 3551 case selectPreviousLine: 3552 runInputAction!previousLine; 3553 break; 3554 case selectNextLine: 3555 runInputAction!nextLine; 3556 break; 3557 case selectToLineStart: 3558 runInputAction!toLineStart; 3559 break; 3560 case selectToLineEnd: 3561 runInputAction!toLineEnd; 3562 break; 3563 case selectToStart: 3564 runInputAction!toStart; 3565 break; 3566 case selectToEnd: 3567 runInputAction!toEnd; 3568 break; 3569 default: 3570 assert(false, "Invalid action"); 3571 } 3572 3573 } 3574 3575 /// Cut selected text to clipboard, clearing the selection. 3576 @(FluidInputAction.cut) 3577 void cut() { 3578 3579 auto snap = snapshot(); 3580 copy(); 3581 pushSnapshot(snap); 3582 selectedValue = null; 3583 3584 } 3585 3586 unittest { 3587 3588 auto root = textInput(); 3589 3590 root.draw(); 3591 root.push("Foo Bar Baz Ban"); 3592 3593 // Move cursor to "Bar" 3594 root.runInputAction!(FluidInputAction.toStart); 3595 root.runInputAction!(FluidInputAction.nextWord); 3596 3597 // Select "Bar Baz " 3598 root.runInputAction!(FluidInputAction.selectNextWord); 3599 root.runInputAction!(FluidInputAction.selectNextWord); 3600 3601 assert(root.io.clipboard == ""); 3602 assert(root.selectedValue == "Bar Baz "); 3603 3604 // Cut the text 3605 root.cut(); 3606 3607 assert(root.io.clipboard == "Bar Baz "); 3608 assert(root.value == "Foo Ban"); 3609 3610 } 3611 3612 unittest { 3613 3614 auto root = textInput(); 3615 3616 root.push("Привет, мир! Это пример текста для тестирования поддержки Unicode во Fluid."); 3617 root.draw(); 3618 root.io.clipboard = "ą"; 3619 3620 root.runInputAction!(FluidInputAction.previousChar); 3621 root.selectionStart = 106; // Before "Unicode" 3622 root.cut(); 3623 3624 assert(root.value == "Привет, мир! Это пример текста для тестирования поддержки ."); 3625 assert(root.io.clipboard == "Unicode во Fluid"); 3626 3627 root.caretIndex = 14; 3628 root.runInputAction!(FluidInputAction.selectNextWord); // мир 3629 root.paste(); 3630 3631 assert(root.value == "Привет, Unicode во Fluid! Это пример текста для тестирования поддержки ."); 3632 3633 } 3634 3635 /// Copy selected text to clipboard. 3636 @(FluidInputAction.copy) 3637 void copy() { 3638 3639 import std.conv : text; 3640 3641 if (isSelecting) 3642 io.clipboard = text(selectedValue); 3643 3644 } 3645 3646 unittest { 3647 3648 auto root = textInput(); 3649 3650 root.draw(); 3651 root.push("Foo Bar Baz Ban"); 3652 3653 // Select all 3654 root.selectAll(); 3655 3656 assert(root.io.clipboard == ""); 3657 3658 root.copy(); 3659 3660 assert(root.io.clipboard == "Foo Bar Baz Ban"); 3661 3662 // Reduce selection by a word 3663 root.runInputAction!(FluidInputAction.selectPreviousWord); 3664 root.copy(); 3665 3666 assert(root.io.clipboard == "Foo Bar Baz "); 3667 assert(root.value == "Foo Bar Baz Ban"); 3668 3669 } 3670 3671 /// Paste text from clipboard. 3672 @(FluidInputAction.paste) 3673 void paste() { 3674 3675 auto snap = snapshot(); 3676 push(io.clipboard); 3677 forcePushSnapshot(snap); 3678 3679 } 3680 3681 unittest { 3682 3683 auto root = textInput(); 3684 3685 root.value = "Foo "; 3686 root.draw(); 3687 root.caretToEnd(); 3688 root.io.clipboard = "Bar"; 3689 3690 assert(root.caretIndex == 4); 3691 assert(root.value == "Foo "); 3692 3693 root.paste(); 3694 3695 assert(root.caretIndex == 7); 3696 assert(root.value == "Foo Bar"); 3697 3698 root.caretToStart(); 3699 root.paste(); 3700 3701 assert(root.caretIndex == 3); 3702 assert(root.value == "BarFoo Bar"); 3703 3704 } 3705 3706 /// Clear the undo/redo action history. 3707 /// 3708 /// Calling this will erase both the undo and redo stack, making it impossible to restore any changes made in prior, 3709 /// through means such as Ctrl+Z and Ctrl+Y. 3710 void clearHistory() { 3711 3712 _undoStack.clear(); 3713 _redoStack.clear(); 3714 3715 } 3716 3717 /// Push the given state snapshot (value, caret & selection) into the undo stack. Refuses to push if the current 3718 /// state can be merged with it, unless `forcePushSnapshot` is used. 3719 /// 3720 /// A snapshot pushed through `forcePushSnapshot` will break continuity — it will not be merged with any other 3721 /// snapshot. 3722 void pushSnapshot(HistoryEntry entry) { 3723 3724 // Compare against current state, so it can be dismissed if it's too similar 3725 auto currentState = snapshot(); 3726 currentState.setPreviousEntry(entry); 3727 3728 // Mark as continuous, so runs of similar characters can be merged together 3729 scope (success) _isContinuous = true; 3730 3731 // No change was made, ignore 3732 if (currentState.diff.isSame()) return; 3733 3734 // Current state is compatible, ignore 3735 if (entry.isContinuous && entry.canMergeWith(currentState)) return; 3736 3737 // Push state 3738 forcePushSnapshot(entry); 3739 3740 } 3741 3742 /// ditto 3743 void forcePushSnapshot(HistoryEntry entry) { 3744 3745 // Break continuity 3746 _isContinuous = false; 3747 3748 // Ignore if the last entry is identical 3749 if (!_undoStack.empty && _undoStack.back == entry) return; 3750 3751 // Truncate the history to match the index, insert the current value. 3752 _undoStack.insertBack(entry); 3753 3754 // Clear the redo stack 3755 _redoStack.clear(); 3756 3757 } 3758 3759 unittest { 3760 3761 auto root = textInput(.multiline); 3762 root.push("Hello, "); 3763 root.runInputAction!(FluidInputAction.breakLine); 3764 root.push("new"); 3765 root.runInputAction!(FluidInputAction.breakLine); 3766 root.push("line"); 3767 root.chop; 3768 root.chopWord; 3769 root.push("few"); 3770 root.push(" lines"); 3771 assert(root.value == "Hello, \nnew\nfew lines"); 3772 3773 // Move back to last chop 3774 root.undo(); 3775 assert(root.value == "Hello, \nnew\n"); 3776 3777 // Test redo 3778 root.redo(); 3779 assert(root.value == "Hello, \nnew\nfew lines"); 3780 root.undo(); 3781 assert(root.value == "Hello, \nnew\n"); 3782 3783 // Move back through isnerts 3784 root.undo(); 3785 assert(root.value == "Hello, \nnew\nline"); 3786 root.undo(); 3787 assert(root.value == "Hello, \nnew\n"); 3788 root.undo(); 3789 assert(root.value == "Hello, \nnew"); 3790 root.undo(); 3791 assert(root.value == "Hello, \n"); 3792 root.undo(); 3793 assert(root.value == "Hello, "); 3794 root.undo(); 3795 assert(root.value == ""); 3796 root.redo(); 3797 assert(root.value == "Hello, "); 3798 root.redo(); 3799 assert(root.value == "Hello, \n"); 3800 root.redo(); 3801 assert(root.value == "Hello, \nnew"); 3802 root.redo(); 3803 assert(root.value == "Hello, \nnew\n"); 3804 root.redo(); 3805 assert(root.value == "Hello, \nnew\nline"); 3806 root.redo(); 3807 assert(root.value == "Hello, \nnew\n"); 3808 root.redo(); 3809 assert(root.value == "Hello, \nnew\nfew lines"); 3810 3811 // Navigate and replace "Hello" 3812 root.caretIndex = 5; 3813 root.runInputAction!(FluidInputAction.selectPreviousWord); 3814 root.push("Hi"); 3815 assert(root.value == "Hi, \nnew\nfew lines"); 3816 assert(root.valueBeforeCaret == "Hi"); 3817 3818 root.undo(); 3819 assert(root.value == "Hello, \nnew\nfew lines"); 3820 assert(root.selectedValue == "Hello"); 3821 3822 root.undo(); 3823 assert(root.value == "Hello, \nnew\n"); 3824 assert(root.valueAfterCaret == ""); 3825 3826 } 3827 3828 unittest { 3829 3830 auto root = textInput(); 3831 3832 foreach (i; 0..4) { 3833 root.caretToStart(); 3834 root.push("a"); 3835 } 3836 3837 assert(root.value == "aaaa"); 3838 assert(root.valueBeforeCaret == "a"); 3839 root.undo(); 3840 assert(root.value == "aaa"); 3841 assert(root.valueBeforeCaret == ""); 3842 root.undo(); 3843 assert(root.value == "aa"); 3844 assert(root.valueBeforeCaret == ""); 3845 root.undo(); 3846 assert(root.value == "a"); 3847 assert(root.valueBeforeCaret == ""); 3848 root.undo(); 3849 assert(root.value == ""); 3850 3851 } 3852 3853 /// Produce a snapshot for the current state. Returns the snapshot. 3854 protected HistoryEntry snapshot() const { 3855 3856 auto entry = HistoryEntry(value, selectionStart, selectionEnd, _isContinuous); 3857 3858 // Get previous entry in the history 3859 if (!_undoStack.empty) 3860 entry.setPreviousEntry(_undoStack.back.value); 3861 3862 return entry; 3863 3864 } 3865 3866 /// Restore state from snapshot 3867 protected HistoryEntry snapshot(HistoryEntry entry) { 3868 3869 value = entry.value; 3870 selectSlice(entry.selectionStart, entry.selectionEnd); 3871 3872 return entry; 3873 3874 } 3875 3876 /// Restore the last value in history. 3877 @(FluidInputAction.undo) 3878 void undo() { 3879 3880 // Nothing to undo 3881 if (_undoStack.empty) return; 3882 3883 // Push the current state to redo stack 3884 _redoStack.insertBack(snapshot); 3885 3886 // Restore the value 3887 this.snapshot = _undoStack.back; 3888 _undoStack.removeBack; 3889 3890 } 3891 3892 /// Perform the last undone action again. 3893 @(FluidInputAction.redo) 3894 void redo() { 3895 3896 // Nothing to redo 3897 if (_redoStack.empty) return; 3898 3899 // Push the current state to undo stack 3900 _undoStack.insertBack(snapshot); 3901 3902 // Restore the value 3903 this.snapshot = _redoStack.back; 3904 _redoStack.removeBack; 3905 3906 } 3907 3908 } 3909 3910 unittest { 3911 3912 auto root = textInput(.nullTheme, .multiline); 3913 auto lineHeight = root.style.getTypeface.lineHeight; 3914 3915 root.value = "First one\nSecond two"; 3916 root.draw(); 3917 3918 // Navigate to the start and select the whole line 3919 root.caretToStart(); 3920 root.runInputAction!(FluidInputAction.selectToLineEnd); 3921 3922 assert(root.selectedValue == "First one"); 3923 assert(root.caretPosition.y < lineHeight); 3924 3925 } 3926 3927 /// `wordFront` and `wordBack` get the word at the beginning or end of given string, respectively. 3928 /// 3929 /// A word is a streak of consecutive characters — non-whitespace, either all alphanumeric or all not — followed by any 3930 /// number of whitespace. 3931 /// 3932 /// Params: 3933 /// text = Text to scan for the word. 3934 /// excludeWhite = If true, whitespace will not be included in the word. 3935 T wordFront(T)(T text, bool excludeWhite = false) { 3936 3937 size_t length; 3938 3939 T result() { return text[0..length]; } 3940 T remaining() { return text[length..$]; } 3941 3942 while (remaining != "") { 3943 3944 // Get the first character 3945 const lastChar = remaining.decodeFrontStatic; 3946 3947 // Exclude white characters if enabled 3948 if (excludeWhite && lastChar.isWhite) break; 3949 3950 length += lastChar.codeLength!(typeof(text[0])); 3951 3952 // Stop if empty 3953 if (remaining == "") break; 3954 3955 const nextChar = remaining.decodeFrontStatic; 3956 3957 // Stop if the next character is a line feed 3958 if (nextChar.only.chomp.empty && !only(lastChar, nextChar).equal("\r\n")) break; 3959 3960 // Continue if the next character is whitespace 3961 // Includes any case where the previous character is followed by whitespace 3962 else if (nextChar.isWhite) continue; 3963 3964 // Stop if whitespace follows a non-white character 3965 else if (lastChar.isWhite) break; 3966 3967 // Stop if the next character has different type 3968 else if (lastChar.isAlphaNum != nextChar.isAlphaNum) break; 3969 3970 } 3971 3972 return result; 3973 3974 } 3975 3976 /// ditto 3977 T wordBack(T)(T text, bool excludeWhite = false) { 3978 3979 size_t length = text.length; 3980 3981 T result() { return text[length..$]; } 3982 T remaining() { return text[0..length]; } 3983 3984 while (remaining != "") { 3985 3986 // Get the first character 3987 const lastChar = remaining.decodeBackStatic; 3988 3989 // Exclude white characters if enabled 3990 if (excludeWhite && lastChar.isWhite) break; 3991 3992 length -= lastChar.codeLength!(typeof(text[0])); 3993 3994 // Stop if empty 3995 if (remaining == "") break; 3996 3997 const nextChar = remaining.decodeBackStatic; 3998 3999 // Stop if the character is a line feed 4000 if (lastChar.only.chomp.empty && !only(nextChar, lastChar).equal("\r\n")) break; 4001 4002 // Continue if the current character is whitespace 4003 // Inverse to `wordFront` 4004 else if (lastChar.isWhite) continue; 4005 4006 // Stop if whitespace follows a non-white character 4007 else if (nextChar.isWhite) break; 4008 4009 // Stop if the next character has different type 4010 else if (lastChar.isAlphaNum != nextChar.isAlphaNum) break; 4011 4012 } 4013 4014 return result; 4015 4016 } 4017 4018 /// `decodeFront` and `decodeBack` variants that do not mutate the range 4019 private dchar decodeFrontStatic(T)(T range) @trusted { 4020 4021 return range.decodeFront; 4022 4023 } 4024 4025 /// ditto 4026 private dchar decodeBackStatic(T)(T range) @trusted { 4027 4028 return range.decodeBack; 4029 4030 } 4031 4032 unittest { 4033 4034 assert("hello world!".wordFront == "hello "); 4035 assert("hello, world!".wordFront == "hello"); 4036 assert("hello world!".wordBack == "!"); 4037 assert("hello world".wordBack == "world"); 4038 assert("hello ".wordBack == "hello "); 4039 4040 assert("witaj świecie!".wordFront == "witaj "); 4041 assert(" świecie!".wordFront == " "); 4042 assert("świecie!".wordFront == "świecie"); 4043 assert("witaj świecie!".wordBack == "!"); 4044 assert("witaj świecie".wordBack == "świecie"); 4045 assert("witaj ".wordBack == "witaj "); 4046 4047 assert("Всем привет!".wordFront == "Всем "); 4048 assert("привет!".wordFront == "привет"); 4049 assert("!".wordFront == "!"); 4050 4051 // dstring 4052 assert("Всем привет!"d.wordFront == "Всем "d); 4053 assert("привет!"d.wordFront == "привет"d); 4054 assert("!"d.wordFront == "!"d); 4055 4056 assert("Всем привет!"d.wordBack == "!"d); 4057 assert("Всем привет"d.wordBack == "привет"d); 4058 assert("Всем "d.wordBack == "Всем "d); 4059 4060 // Whitespace exclusion 4061 assert("witaj świecie!".wordFront(true) == "witaj"); 4062 assert(" świecie!".wordFront(true) == ""); 4063 assert("witaj świecie".wordBack(true) == "świecie"); 4064 assert("witaj ".wordBack(true) == ""); 4065 4066 } 4067 4068 unittest { 4069 4070 assert("\nabc\n".wordFront == "\n"); 4071 assert("\n abc\n".wordFront == "\n "); 4072 assert("abc\n".wordFront == "abc"); 4073 assert("abc \n".wordFront == "abc "); 4074 assert(" \n".wordFront == " "); 4075 assert("\n abc".wordFront == "\n "); 4076 4077 assert("\nabc\n".wordBack == "\n"); 4078 assert("\nabc".wordBack == "abc"); 4079 assert("abc \n".wordBack == "\n"); 4080 assert("abc ".wordFront == "abc "); 4081 assert("\nabc\n ".wordBack == "\n "); 4082 assert("\nabc\n a".wordBack == "a"); 4083 4084 assert("\r\nabc\r\n".wordFront == "\r\n"); 4085 assert("\r\n abc\r\n".wordFront == "\r\n "); 4086 assert("abc\r\n".wordFront == "abc"); 4087 assert("abc \r\n".wordFront == "abc "); 4088 assert(" \r\n".wordFront == " "); 4089 assert("\r\n abc".wordFront == "\r\n "); 4090 4091 assert("\r\nabc\r\n".wordBack == "\r\n"); 4092 assert("\r\nabc".wordBack == "abc"); 4093 assert("abc \r\n".wordBack == "\r\n"); 4094 assert("abc ".wordFront == "abc "); 4095 assert("\r\nabc\r\n ".wordBack == "\r\n "); 4096 assert("\r\nabc\r\n a".wordBack == "a"); 4097 4098 } 4099 4100 @("TextInput automatically updates scrolling ancestors") 4101 unittest { 4102 4103 // Note: This theme relies on properties of the default typeface 4104 4105 import fluid.scroll; 4106 4107 const viewportHeight = 50; 4108 4109 auto theme = nullTheme.derive( 4110 rule!Node( 4111 Rule.typeface = Style.defaultTypeface, 4112 Rule.fontSize = 20.pt, 4113 Rule.textColor = color("#fff"), 4114 Rule.backgroundColor = color("#000"), 4115 ), 4116 ); 4117 auto input = multilineInput(); 4118 auto root = vscrollFrame(theme, input); 4119 auto io = new HeadlessBackend(Vector2(200, viewportHeight)); 4120 root.io = io; 4121 4122 root.draw(); 4123 assert(root.scroll == 0); 4124 4125 // Begin typing 4126 input.push("FLUID\nIS\nAWESOME"); 4127 input.caretToStart(); 4128 input.push("FLUID\nIS\nAWESOME\n"); 4129 root.draw(); 4130 root.draw(); 4131 4132 const focusBox = input.focusBoxImpl(Rectangle(0, 0, 200, 50)); 4133 4134 assert(focusBox.start == input.caretPosition); 4135 assert(focusBox.end.y - viewportHeight == root.scroll); 4136 4137 4138 }