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