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