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 import fluid.io.focus; 27 import fluid.io.hover; 28 import fluid.io.canvas; 29 import fluid.io.overlay; 30 import fluid.io.clipboard; 31 32 @safe: 33 34 35 /// Constructor parameter, enables multiline input in `TextInput`. 36 auto multiline(bool value = true) { 37 38 struct Multiline { 39 40 bool value; 41 42 void apply(TextInput node) { 43 node.multiline = value; 44 } 45 46 } 47 48 return Multiline(value); 49 50 } 51 52 53 /// Text input field. 54 alias textInput = nodeBuilder!TextInput; 55 alias lineInput = nodeBuilder!TextInput; 56 alias multilineInput = nodeBuilder!(TextInput, (a) { 57 a.multiline = true; 58 }); 59 60 /// ditto 61 class TextInput : InputNode!Node, FluidScrollable, HoverScrollable { 62 63 mixin enableInputActions; 64 65 CanvasIO canvasIO; 66 ClipboardIO clipboardIO; 67 OverlayIO overlayIO; 68 69 public { 70 71 /// Size of the field. 72 auto size = Vector2(200, 0); 73 74 /// A placeholder text for the field, displayed when the field is empty. Style using `emptyStyle`. 75 Rope placeholder; 76 77 /// Time of the last interaction with the input. 78 SysTime lastTouch; 79 80 /// Reference horizontal (X) position for vertical movement. Relative to the input's top-left corner. 81 /// 82 /// To make sure that vertical navigation via up/down arrows stays in the same column as it traverses lines of 83 /// varying width, a reference position is saved to match the visual position of the last column. This is then 84 /// used to match against characters on adjacent lines to find the one that is the closest. This ensures that 85 /// even if cursor travels a large vertical distance, it stays close to the original horizontal position, 86 /// without sliding to the left or right in the process. 87 /// 88 /// `horizontalAnchor` is updated any time the cursor moves horizontally, including mouse navigation. 89 float horizontalAnchor; 90 91 /// Context menu for this input. 92 PopupFrame contextMenu; 93 94 /// Underlying label controlling the content. 95 ContentLabel contentLabel; 96 97 /// Maximum entries in the history. 98 int maxHistorySize = 256; 99 100 } 101 102 protected { 103 104 static struct HistoryEntry { 105 106 Rope value; 107 size_t selectionStart; 108 size_t selectionEnd; 109 110 /// If true, the entry results from an action that was executed immediately after the last action, without 111 /// changing caret position in the meantime. 112 bool isContinuous; 113 114 /// Change made by this entry. 115 /// 116 /// `first` and `second` should represent the old and new value respectively; `second` is effectively a 117 /// substring of `value`. 118 Rope.DiffRegion diff; 119 120 /// A history entry is "additive" if it adds any new content to the input. An entry is "subtractive" if it 121 /// removes any part of the input. An entry that replaces content is simultaneously additive and 122 /// subtractive. 123 /// 124 /// See_Also: `setPreviousEntry`, `canMergeWith` 125 bool isAdditive; 126 127 /// ditto. 128 bool isSubtractive; 129 130 /// Set `isAdditive` and `isSubtractive` based on the given text representing the last input. 131 void setPreviousEntry(HistoryEntry entry) { 132 133 setPreviousEntry(entry.value); 134 135 } 136 137 /// ditto 138 void setPreviousEntry(Rope previousValue) { 139 140 this.diff = previousValue.diff(value); 141 this.isAdditive = diff.second.length != 0; 142 this.isSubtractive = diff.first.length != 0; 143 144 } 145 146 /// Check if this entry can be merged with (newer) entry given its text content. This is used to combine 147 /// runs of similar actions together, for example when typing a word, the whole word will form a single 148 /// entry, instead of creating separate entries per character. 149 /// 150 /// Two entries can be combined if they are: 151 /// 152 /// 1. Both additive, and the latter is not subtractive. This combines runs input, including if the first 153 /// item in the run replaces some text. However, replacing text will break an existing chain of actions. 154 /// 2. Both subtractive, and neither is additive. 155 /// 156 /// See_Also: `isAdditive` 157 bool canMergeWith(Rope nextValue) const { 158 159 // Create a dummy entry based on the text 160 auto nextEntry = HistoryEntry(nextValue, 0, 0); 161 nextEntry.setPreviousEntry(value); 162 163 return canMergeWith(nextEntry); 164 165 } 166 167 /// ditto 168 bool canMergeWith(HistoryEntry nextEntry) const { 169 170 const mergeAdditive = this.isAdditive 171 && nextEntry.isAdditive 172 && !nextEntry.isSubtractive; 173 174 if (mergeAdditive) return true; 175 176 const mergeSubtractive = !this.isAdditive 177 && this.isSubtractive 178 && !nextEntry.isAdditive 179 && nextEntry.isSubtractive; 180 181 return mergeSubtractive; 182 183 } 184 185 } 186 187 /// If true, movement actions select text, as opposed to clearing selection. 188 bool selectionMovement; 189 190 /// Last padding box assigned to this node, with scroll applied. 191 Rectangle _inner = Rectangle(0, 0, 0, 0); 192 193 /// If true, the caret index has not changed since last `pushSnapshot`. 194 bool _isContinuous; 195 196 /// Current action history, expressed as two stacks, indicating undoable and redoable actions, controllable via 197 /// `snapshot`, `pushSnapshot`, `undo` and `redo`. 198 DList!HistoryEntry _undoStack; 199 200 /// ditto 201 DList!HistoryEntry _redoStack; 202 203 /// The line height used by this input, in pixels. 204 /// 205 /// Temporary workaround/helper function to get line height in pixels, as opposed to dots 206 /// given by `Typeface`. 207 float lineHeight; 208 209 } 210 211 private { 212 213 /// Action used to keep the text input in view. 214 ScrollIntoViewAction _scrollAction; 215 216 /// Buffer used to store recently inserted text. 217 /// See_Also: `buffer` 218 char[] _buffer; 219 220 /// Number of bytes stored in the buffer. 221 size_t _usedBufferSize; 222 223 /// Node the buffer is stored in. 224 RopeNode* _bufferNode; 225 invariant(_bufferNode is null || _bufferNode.left.value.sameTail(_buffer[0 .. _usedBufferSize]), 226 "_bufferNode must be in sync with _buffer"); 227 228 /// Value of the text input. 229 Rope _value; 230 231 /// Available horizontal space. 232 float _availableWidth = float.nan; 233 234 /// Visual position of the caret. 235 Vector2 _caretPosition; 236 237 /// Index of the caret. 238 ptrdiff_t _caretIndex; 239 240 /// Reference point; beginning of selection. Set to -1 if there is no start. 241 ptrdiff_t _selectionStart; 242 243 /// Current horizontal visual offset of the label. 244 float _scroll = 0; 245 246 } 247 248 /// Create a text input. 249 /// Params: 250 /// placeholder = Placeholder text for the field. 251 /// submitted = Callback for when the field is submitted. 252 this(string placeholder = "", void delegate() @trusted submitted = null) { 253 254 import fluid.button; 255 256 this.placeholder = placeholder; 257 this.submitted = submitted; 258 this.lastTouch = Clock.currTime; 259 this.contentLabel = new typeof(contentLabel); 260 261 // Make single line the default 262 contentLabel.isWrapDisabled = true; 263 264 // Enable edit mode 265 contentLabel.text.hasFastEdits = true; 266 267 // Create the context menu 268 this.contextMenu = popupFrame( 269 button( 270 .layout!"fill", 271 "Cut", 272 delegate { 273 cut(); 274 contextMenu.hide(); 275 } 276 ), 277 button( 278 .layout!"fill", 279 "Copy", 280 delegate { 281 copy(); 282 contextMenu.hide(); 283 } 284 ), 285 button( 286 .layout!"fill", 287 "Paste", 288 delegate { 289 paste(); 290 contextMenu.hide(); 291 } 292 ), 293 ); 294 295 } 296 297 static class ContentLabel : Label { 298 299 bool isPlaceholder; 300 301 this() { 302 super(""); 303 } 304 305 override bool hoveredImpl(Rectangle, Vector2) const { 306 return false; 307 } 308 309 override void drawImpl(Rectangle outer, Rectangle inner) { 310 311 // Don't draw background 312 const style = pickStyle(); 313 text.draw(canvasIO, style, inner.start); 314 315 } 316 317 override void reloadStyles() { 318 // Do not load styles 319 } 320 321 override Style pickStyle() { 322 323 // Always use default style 324 return style; 325 326 } 327 328 } 329 330 alias opEquals = Node.opEquals; 331 override bool opEquals(const Object other) const { 332 return super.opEquals(other); 333 } 334 335 /// Mark the text input as modified. 336 void touch() { 337 338 lastTouch = Clock.currTime; 339 scrollIntoView(); 340 341 } 342 343 /// Mark the text input as modified and fire the "changed" event. 344 void touchText() { 345 346 touch(); 347 if (changed) changed(); 348 349 } 350 351 /// Scroll ancestors so the text input becomes visible. 352 /// 353 /// `TextInput` keeps its own instance of `ScrollIntoViewAction`, reusing it every time it is needed. 354 /// 355 /// Params: 356 /// alignToTop = If true, the top of the element will be aligned to the top of the scrollable area. 357 ScrollIntoViewAction scrollIntoView(bool alignToTop = false) { 358 359 // Create the action 360 if (!_scrollAction) { 361 _scrollAction = .scrollIntoView(this, alignToTop); 362 } 363 else { 364 _scrollAction.reset(alignToTop); 365 queueAction(_scrollAction); 366 } 367 368 return _scrollAction; 369 370 } 371 372 /// Value written in the input. 373 inout(Rope) value() inout { 374 375 return _value; 376 377 } 378 379 /// ditto 380 Rope value(Rope newValue) { 381 382 auto withoutLineFeeds = Typeface.lineSplitter(newValue).joiner; 383 384 // Single line mode — filter vertical space out 385 if (!multiline && !newValue.equal(withoutLineFeeds)) { 386 387 newValue = Typeface.lineSplitter(newValue).join(' '); 388 389 } 390 391 _value = newValue; 392 _bufferNode = null; 393 updateSize(); 394 return value; 395 396 } 397 398 /// ditto 399 Rope value(const(char)[] value) { 400 401 return this.value(Rope(value)); 402 403 } 404 405 /// If true, this input is currently empty. 406 bool isEmpty() const { 407 408 return value == ""; 409 410 } 411 412 /// If true, this input accepts multiple inputs in the input; pressing "enter" will start a new line. 413 /// 414 /// Even if multiline is off, the value may still contain line feeds if inserted from code. 415 bool multiline() const { 416 417 return !contentLabel.isWrapDisabled; 418 419 } 420 421 /// ditto 422 bool multiline(bool value) { 423 424 contentLabel.isWrapDisabled = !value; 425 return value; 426 427 } 428 429 /// Current horizontal visual offset of the label. 430 float scroll() const { 431 432 return _scroll; 433 434 } 435 436 /// Set scroll value. 437 float scroll(float value) { 438 439 const limit = max(0, contentLabel.minSize.x - _inner.w); 440 441 return _scroll = value.clamp(0, limit); 442 443 } 444 445 /// 446 bool canScroll(Vector2 value) const { 447 448 return clamp(scroll + value.x, 0, _availableWidth) != scroll; 449 450 } 451 452 final bool canScroll(const HoverPointer pointer) const { 453 return canScroll(pointer.scroll); 454 } 455 456 /// Get the current style for the label. 457 /// Params: 458 /// style = Current style of the TextInput. 459 Style pickLabelStyle(Style style) { 460 461 // Pick style from the input 462 auto result = style; 463 464 // Remove spacing 465 result.margin = 0; 466 result.padding = 0; 467 result.border = 0; 468 469 return result; 470 471 } 472 473 final Style pickLabelStyle() { 474 475 return pickLabelStyle(pickStyle); 476 477 } 478 479 /// Get or set text preceding the caret. 480 Rope valueBeforeCaret() const { 481 482 return value[0 .. caretIndex]; 483 484 } 485 486 /// ditto 487 Rope valueBeforeCaret(Rope newValue) { 488 489 // Replace the data 490 if (valueAfterCaret.empty) 491 value = newValue; 492 else 493 value = newValue ~ valueAfterCaret; 494 495 caretIndex = newValue.length; 496 updateSize(); 497 498 return value[0 .. caretIndex]; 499 500 } 501 502 /// ditto 503 Rope valueBeforeCaret(const(char)[] newValue) { 504 505 return valueBeforeCaret(Rope(newValue)); 506 507 } 508 509 /// Get or set currently selected text. 510 Rope selectedValue() inout { 511 512 return value[selectionLowIndex .. selectionHighIndex]; 513 514 } 515 516 /// ditto 517 Rope selectedValue(Rope newValue) { 518 519 const isLow = caretIndex == selectionStart; 520 const low = selectionLowIndex; 521 const high = selectionHighIndex; 522 523 value = value.replace(low, high, newValue); 524 caretIndex = low + newValue.length; 525 updateSize(); 526 clearSelection(); 527 528 return value[low .. low + newValue.length]; 529 530 } 531 532 /// ditto 533 Rope selectedValue(const(char)[] newValue) { 534 535 return selectedValue(Rope(newValue)); 536 537 } 538 539 /// Get or set text following the caret. 540 Rope valueAfterCaret() inout { 541 542 return value[caretIndex .. $]; 543 544 } 545 546 /// ditto 547 Rope valueAfterCaret(Rope newValue) { 548 549 // Replace the data 550 if (valueBeforeCaret.empty) 551 value = newValue; 552 else 553 value = valueBeforeCaret ~ newValue; 554 555 updateSize(); 556 557 return value[caretIndex .. $]; 558 559 } 560 561 /// ditto 562 Rope valueAfterCaret(const(char)[] value) { 563 564 return valueAfterCaret(Rope(value)); 565 566 } 567 568 /// Visual position of the caret, relative to the top-left corner of the input. 569 Vector2 caretPosition() const { 570 571 // Calculated in caretPositionImpl 572 return _caretPosition; 573 574 } 575 576 /// Index of the character, byte-wise. 577 ptrdiff_t caretIndex() const { 578 579 return _caretIndex.clamp(0, value.length); 580 581 } 582 583 /// ditto 584 ptrdiff_t caretIndex(ptrdiff_t index) { 585 586 if (!isSelecting) { 587 _selectionStart = index; 588 } 589 590 touch(); 591 _bufferNode = null; 592 _isContinuous = false; 593 return _caretIndex = index; 594 595 } 596 597 /// If true, there's an active selection. 598 bool isSelecting() const { 599 600 return selectionStart != caretIndex || selectionMovement; 601 602 } 603 604 /// Low index of the selection, left boundary, first index. 605 ptrdiff_t selectionLowIndex() const { 606 607 return min(selectionStart, selectionEnd); 608 609 } 610 611 /// High index of the selection, right boundary, second index. 612 ptrdiff_t selectionHighIndex() const { 613 614 return max(selectionStart, selectionEnd); 615 616 } 617 618 /// Point where selection begins. Caret is the other end of the selection. 619 /// 620 /// Note that `selectionStart` may be greater than `selectionEnd`. If you need them in order, use 621 /// `selectionLowIndex` and `selectionHighIndex`. 622 ptrdiff_t selectionStart() const { 623 624 // Selection is present 625 return _selectionStart.clamp(0, value.length); 626 627 } 628 629 /// ditto 630 ptrdiff_t selectionStart(ptrdiff_t value) { 631 632 return _selectionStart = value; 633 634 } 635 636 /// Point where selection ends. Corresponds to caret position. 637 alias selectionEnd = caretIndex; 638 639 /// Select a part of text. This is preferred to setting `selectionStart` & `selectionEnd` directly, since the two 640 /// properties are synchronized together and a change might be ignored. 641 void selectSlice(size_t start, size_t end) 642 in (end <= value.length, format!"Slice [%s .. %s] exceeds textInput value length of %s"(start, end, value.length)) 643 do { 644 645 selectionEnd = end; 646 selectionStart = start; 647 648 } 649 650 /// 651 void clearSelection() { 652 653 _selectionStart = _caretIndex; 654 655 } 656 657 /// Clear selection if selection movement is disabled. 658 protected void moveOrClearSelection() { 659 660 if (!selectionMovement) { 661 662 clearSelection(); 663 664 } 665 666 } 667 668 protected override void resizeImpl(Vector2 area) { 669 670 import std.math : isNaN; 671 672 use(canvasIO); 673 use(focusIO); 674 use(hoverIO); 675 use(overlayIO); 676 use(clipboardIO); 677 678 super.resizeImpl(area); 679 680 // Set the size 681 minSize = size; 682 683 // Set the label text 684 contentLabel.isPlaceholder = value == ""; 685 contentLabel.text = value == "" ? placeholder : value; 686 687 const isFill = layout.nodeAlign[0] == NodeAlign.fill; 688 689 _availableWidth = isFill 690 ? area.x 691 : size.x; 692 693 const textArea = multiline 694 ? Vector2(_availableWidth, area.y) 695 : Vector2(0, size.y); 696 697 // Resize the label, and remove the spacing 698 contentLabel.style = pickLabelStyle(style); 699 resizeChild(contentLabel, textArea); 700 701 const scale = canvasIO 702 ? canvasIO.toDots(Vector2(0, 1)).y 703 : io.hidpiScale.y; 704 705 lineHeight = style.getTypeface.lineHeight / scale; 706 707 const minLines = multiline ? 3 : 1; 708 709 // Set height to at least the font size, or total text size 710 minSize.y = max(minSize.y, lineHeight * minLines, contentLabel.minSize.y); 711 712 // Locate the cursor 713 updateCaretPosition(); 714 715 // Horizontal anchor is not set, update it 716 if (horizontalAnchor.isNaN) 717 horizontalAnchor = caretPosition.x; 718 719 } 720 721 /// Update the caret position to match the caret index. 722 /// 723 /// ## preferNextLine 724 /// 725 /// Determines if, in case text wraps over the new line, and the caret is in an ambiguous position, the caret 726 /// will move to the next line, or stay on the previous one. Usually `false`, except for arrow keys and the 727 /// "home" key. 728 /// 729 /// In cases where the text wraps over to the new line due to lack of space, the implied line break creates an 730 /// ambiguous position for the caret. The caret may be placed either at the end of the original line, or be put 731 /// on the newly created line: 732 /// 733 /// --- 734 /// Lorem ipsum dolor sit amet, consectetur | 735 /// |adipiscing elit, sed do eiusmod tempor 736 /// --- 737 /// 738 /// Depending on the situation, either position may be preferable. Keep in mind that the caret position influences 739 /// further movement, particularly when navigating using the down and up arrows. In case the caret is at the 740 /// end of the line, it should stay close to the end, but when it's at the beginning, it should stay close to the 741 /// start. 742 /// 743 /// This is not an issue at all on explicitly created lines, since the caret position is easily decided upon 744 /// depending if it is preceding the line break, or if it's following one. This property is only used for implicitly 745 /// created lines. 746 void updateCaretPosition(bool preferNextLine = false) { 747 748 import std.math : isNaN; 749 750 // No available width, waiting for resize 751 if (_availableWidth.isNaN) { 752 _caretPosition.x = float.nan; 753 return; 754 } 755 756 _caretPosition = caretPositionImpl(_availableWidth, preferNextLine); 757 758 const scrolledCaret = caretPosition.x - scroll; 759 760 // Scroll to make sure the caret is always in view 761 const scrollOffset 762 = scrolledCaret > _inner.width ? scrolledCaret - _inner.width 763 : scrolledCaret < 0 ? scrolledCaret 764 : 0; 765 766 // Set the scroll 767 scroll = multiline 768 ? 0 769 : scroll + scrollOffset; 770 771 } 772 773 /// Find the closest index to the given position. 774 /// Returns: Index of the character. The index may be equal to character length. 775 size_t nearestCharacter(Vector2 needlePx) { 776 777 import std.math : abs; 778 779 auto ruler = textRuler(); 780 auto typeface = ruler.typeface; 781 782 const needle = canvasIO 783 ? canvasIO.toDots(needlePx) 784 : Vector2( 785 io.hidpiScale.x * needlePx.x, 786 io.hidpiScale.y * needlePx.y, 787 ); 788 789 struct Position { 790 size_t index; 791 Vector2 position; 792 } 793 794 /// Returns the position (inside the word) of the character that is the closest to the needle. 795 Position closest(Vector2 startPosition, Vector2 endPosition, const Rope word) { 796 797 // Needle is before or after the word 798 if (needle.x <= startPosition.x) return Position(0, startPosition); 799 if (needle.x >= endPosition.x) return Position(word.length, endPosition); 800 801 size_t index; 802 auto match = Position(0, startPosition); 803 804 // Search inside the word 805 while (index < word.length) { 806 807 decode(word[], index); // index by reference 808 809 auto size = typeface.measure(word[0..index]); 810 auto end = startPosition.x + size.x; 811 auto half = (match.position.x + end)/2; 812 813 // Hit left side of this character, or right side of the previous, return the previous character 814 if (needle.x < half) break; 815 816 match.index = index; 817 match.position.x = startPosition.x + size.x; 818 819 } 820 821 return match; 822 823 } 824 825 auto result = Position(0, Vector2(float.infinity, float.infinity)); 826 827 // Search for a matching character on adjacent lines 828 search: foreach (index, line; typeface.lineSplitterIndex(value[])) { 829 830 ruler.startLine(); 831 832 // Each word is a single, unbreakable unit 833 foreach (word, penPosition; typeface.eachWord(ruler, line, multiline)) { 834 835 scope (exit) index += word.length; 836 837 // Find the middle of the word to use as a reference for vertical search 838 const middleY = ruler.caret.center.y; 839 840 // Skip this word if the closest match is closer vertically 841 if (abs(result.position.y - needle.y) < abs(middleY - needle.y)) continue; 842 843 // Find the words' closest horizontal position 844 const newLine = ruler.wordLineIndex == 1; 845 const startPosition = Vector2(penPosition.x, middleY); 846 const endPosition = Vector2(ruler.penPosition.x, middleY); 847 const reference = closest(startPosition, endPosition, word); 848 849 // Skip if the closest match is still closer than the chosen reference 850 if (!newLine && abs(result.position.x - needle.x) < abs(reference.position.x - needle.x)) continue; 851 852 // Save the result if it's better 853 result = reference; 854 result.index += index; 855 856 } 857 858 } 859 860 return result.index; 861 862 } 863 864 /// Get the current buffer. 865 /// 866 /// The buffer is used to store all content inserted through `push`. 867 protected inout(char)[] buffer() inout { 868 869 return _buffer; 870 871 } 872 873 /// Get the used size of the buffer. 874 protected ref inout(size_t) usedBufferSize() inout { 875 876 return _usedBufferSize; 877 878 } 879 880 /// Get the filled part of the buffer. 881 protected inout(char)[] usedBuffer() inout { 882 883 return _buffer[0 .. _usedBufferSize]; 884 885 } 886 887 /// Get the empty part of the buffer. 888 protected inout(char)[] freeBuffer() inout { 889 890 return _buffer[_usedBufferSize .. $]; 891 892 } 893 894 /// Request a new or a larger buffer. 895 /// Params: 896 /// minimumSize = Minimum size to allocate for the buffer. 897 protected void newBuffer(size_t minimumSize = 64) { 898 899 const newSize = max(minimumSize, 64); 900 901 _buffer = new char[newSize]; 902 usedBufferSize = 0; 903 904 } 905 906 protected Vector2 caretPositionImpl(float textWidth, bool preferNextLine) { 907 908 Rope unbreakableChars(Rope value) { 909 910 // Split on lines 911 auto lines = Typeface.lineSplitter(value); 912 if (lines.empty) return value.init; 913 914 // Split on words 915 auto chunks = Typeface.defaultWordChunks(lines.front); 916 if (chunks.empty) return value.init; 917 918 // Return empty string if the result starts with whitespace 919 if (chunks.front.byDchar.front.isWhite) return value.init; 920 921 // Return first word only 922 return chunks.front; 923 924 } 925 926 // Check if the caret follows unbreakable characters 927 const head = unbreakableChars( 928 valueBeforeCaret.wordBack(true) 929 ); 930 931 // If the caret is surrounded by unbreakable characters, include them in the output to make sure the 932 // word is wrapped correctly 933 const tail = preferNextLine || !head.empty 934 ? unbreakableChars(valueAfterCaret) 935 : Rope.init; 936 937 auto typeface = style.getTypeface; 938 auto ruler = textRuler(); 939 auto slice = value[0 .. caretIndex + tail.length]; 940 941 // Measure text until the caret; include the word that follows to keep proper wrapping 942 typeface.measure(ruler, slice, multiline); 943 944 auto caretPosition = ruler.caret.start; 945 946 // Measure the word itself, and remove it 947 caretPosition.x -= typeface.measure(tail[]).x; 948 949 if (canvasIO) { 950 return canvasIO.fromDots(caretPosition); 951 } 952 else { 953 return Vector2( 954 caretPosition.x / io.hidpiScale.x, 955 caretPosition.y / io.hidpiScale.y, 956 ); 957 } 958 959 } 960 961 protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted { 962 963 auto style = pickStyle(); 964 965 // Fill the background 966 style.drawBackground(tree.io, canvasIO, outer); 967 968 // Copy style to the label 969 contentLabel.style = pickLabelStyle(style); 970 971 // Scroll the inner rectangle 972 auto scrolledInner = inner; 973 scrolledInner.x -= scroll; 974 975 // Save the inner box 976 _inner = scrolledInner; 977 978 // Update scroll to match the new box 979 scroll = scroll; 980 981 // Increase the size of the inner box so that tree doesn't turn on scissors mode on its own 982 scrolledInner.w += scroll; 983 984 // New I/O 985 if (canvasIO) { 986 987 const lastArea = canvasIO.intersectCrop(outer); 988 scope (exit) canvasIO.cropArea = lastArea; 989 990 // Draw the contents 991 drawContents(inner, scrolledInner); 992 993 } 994 995 // Old I/O 996 else { 997 998 const lastScissors = tree.pushScissors(outer); 999 scope (exit) tree.popScissors(lastScissors); 1000 1001 // Draw the contents 1002 drawContents(inner, scrolledInner); 1003 1004 } 1005 1006 } 1007 1008 protected void drawContents(Rectangle inner, Rectangle scrolledInner) { 1009 1010 // Draw selection 1011 drawSelection(scrolledInner); 1012 1013 // Draw the text 1014 drawChild(contentLabel, scrolledInner); 1015 1016 // Draw the caret 1017 drawCaret(scrolledInner); 1018 1019 } 1020 1021 protected void drawCaret(Rectangle inner) { 1022 1023 // Ignore the rest if the node isn't focused 1024 if (!isFocused || isDisabledInherited) return; 1025 1026 // Add a blinking caret 1027 if (showCaret) { 1028 1029 const lineHeight = this.lineHeight; 1030 const margin = lineHeight / 10f; 1031 const relativeCaretPosition = this.caretPosition(); 1032 const caretPosition = start(inner) + relativeCaretPosition; 1033 1034 // Draw the caret 1035 if (canvasIO) { 1036 canvasIO.drawLine( 1037 caretPosition + Vector2(0, margin), 1038 caretPosition - Vector2(0, margin - lineHeight), 1039 1, 1040 style.textColor); 1041 } 1042 else { 1043 io.drawLine( 1044 caretPosition + Vector2(0, margin), 1045 caretPosition - Vector2(0, margin - lineHeight), 1046 style.textColor, 1047 ); 1048 } 1049 1050 } 1051 1052 } 1053 1054 override Rectangle focusBoxImpl(Rectangle inner) const { 1055 1056 const position = inner.start + caretPosition; 1057 1058 return Rectangle( 1059 position.tupleof, 1060 1, lineHeight 1061 ); 1062 1063 } 1064 1065 /// Get an appropriate text ruler for this input. 1066 protected TextRuler textRuler() { 1067 1068 return TextRuler(style.getTypeface, multiline ? _availableWidth : float.nan); 1069 1070 } 1071 1072 /// Draw selection, if applicable. 1073 protected void drawSelection(Rectangle inner) { 1074 1075 // Ignore if selection is empty 1076 if (selectionStart == selectionEnd) return; 1077 1078 const low = selectionLowIndex; 1079 const high = selectionHighIndex; 1080 1081 Vector2 scale(Vector2 input) { 1082 if (canvasIO) { 1083 return canvasIO.fromDots(input); 1084 } 1085 else { 1086 return Vector2( 1087 input.x / io.hidpiScale.x, 1088 input.y / io.hidpiScale.y, 1089 ); 1090 } 1091 } 1092 1093 auto style = pickStyle(); 1094 auto typeface = style.getTypeface; 1095 auto ruler = textRuler(); 1096 1097 Vector2 lineStart; 1098 Vector2 lineEnd; 1099 1100 // Run through the text 1101 foreach (index, line; typeface.lineSplitterIndex(value)) { 1102 1103 ruler.startLine(); 1104 1105 // Each word is a single, unbreakable unit 1106 foreach (word, penPosition; typeface.eachWord(ruler, line, multiline)) { 1107 1108 const caret = ruler.caret(penPosition); 1109 const startIndex = index; 1110 const endIndex = index = index + word.length; 1111 1112 const newLine = ruler.wordLineIndex == 1; 1113 1114 scope (exit) lineEnd = ruler.caret.end; 1115 1116 // New line started, flush the line 1117 if (newLine && startIndex > low) { 1118 1119 const rect = Rectangle( 1120 (inner.start + scale(lineStart)).tupleof, 1121 scale(lineEnd - lineStart).tupleof 1122 ); 1123 1124 lineStart = caret.start; 1125 1126 if (canvasIO) { 1127 canvasIO.drawRectangle(rect, style.selectionBackgroundColor); 1128 } 1129 else { 1130 io.drawRectangle(rect, style.selectionBackgroundColor); 1131 } 1132 1133 } 1134 1135 // Selection starts here 1136 if (startIndex <= low && low <= endIndex) { 1137 1138 const dent = typeface.measure(word[0 .. low - startIndex]); 1139 1140 lineStart = caret.start + Vector2(dent.x, 0); 1141 1142 } 1143 1144 // Selection ends here 1145 if (startIndex <= high && high <= endIndex) { 1146 1147 const dent = typeface.measure(word[0 .. high - startIndex]); 1148 const lineEnd = caret.end + Vector2(dent.x, 0); 1149 const rect = Rectangle( 1150 (inner.start + scale(lineStart)).tupleof, 1151 scale(lineEnd - lineStart).tupleof 1152 ); 1153 1154 if (canvasIO) { 1155 canvasIO.drawRectangle(rect, style.selectionBackgroundColor); 1156 } 1157 else { 1158 io.drawRectangle(rect, style.selectionBackgroundColor); 1159 } 1160 return; 1161 1162 } 1163 1164 } 1165 1166 } 1167 1168 } 1169 1170 protected bool showCaret() { 1171 1172 auto timeSecs = (Clock.currTime - lastTouch).total!"seconds"; 1173 1174 // Add a blinking caret if there is no selection 1175 return selectionStart == selectionEnd && timeSecs % 2 == 0; 1176 1177 } 1178 1179 protected override bool keyboardImpl() { 1180 1181 import std.uni : isAlpha, isWhite; 1182 import std.range : back; 1183 1184 bool changed; 1185 1186 // Read text off FocusIO 1187 if (focusIO) { 1188 1189 int offset; 1190 char[1024] buffer; 1191 1192 // Read text 1193 while (true) { 1194 1195 // Push the text 1196 if (auto text = focusIO.readText(buffer, offset)) { 1197 push(text); 1198 } 1199 else break; 1200 1201 } 1202 1203 // Mark as changed 1204 if (offset != 0) { 1205 changed = true; 1206 } 1207 1208 } 1209 1210 // Get pressed key 1211 else while (true) { 1212 1213 // Read text 1214 if (const key = io.inputCharacter) { 1215 1216 // Append to char arrays 1217 push(key); 1218 changed = true; 1219 1220 } 1221 1222 // Stop if there's nothing left 1223 else break; 1224 1225 } 1226 1227 // Typed something 1228 if (changed) { 1229 1230 // Trigger the callback 1231 touchText(); 1232 1233 return true; 1234 1235 } 1236 1237 return true; 1238 1239 } 1240 1241 /// Push a character or string to the input. 1242 void push(dchar character) { 1243 1244 char[4] buffer; 1245 1246 auto size = buffer.encode(character); 1247 push(buffer[0..size]); 1248 1249 } 1250 1251 /// ditto 1252 void push(scope const(char)[] ch) 1253 out (; _bufferNode, "_bufferNode must exist after pushing to buffer") 1254 do { 1255 1256 // Move the buffer node into here; move it back when done 1257 auto bufferNode = _bufferNode; 1258 _bufferNode = null; 1259 scope (exit) _bufferNode = bufferNode; 1260 1261 // Not enough space in the buffer, allocate more 1262 if (freeBuffer.length <= ch.length) { 1263 1264 newBuffer(ch.length); 1265 bufferNode = null; 1266 1267 } 1268 1269 auto slice = freeBuffer[0 .. ch.length]; 1270 1271 // Save the data in the buffer, unless they're the same 1272 if (slice[] !is ch[]) { 1273 slice[] = ch[]; 1274 } 1275 _usedBufferSize += ch.length; 1276 1277 // Selection is active, overwrite it 1278 if (isSelecting) { 1279 1280 bufferNode = new RopeNode(Rope(slice), Rope.init); 1281 push(Rope(bufferNode)); 1282 return; 1283 1284 } 1285 1286 // The above `if` handles the one case where `push` doesn't directly add new characters to the text. 1287 // From here, appending can be optimized by memorizing the node we create to add the text, and reusing it 1288 // afterwards. This way, we avoid creating many one element nodes. 1289 1290 size_t originalLength; 1291 1292 // Make sure there is a node to write to 1293 if (!bufferNode) 1294 bufferNode = new RopeNode(Rope(slice), Rope.init); 1295 1296 // If writing in a single sequence, reuse the last inserted node 1297 else { 1298 1299 originalLength = bufferNode.length; 1300 1301 // Append the character to its value 1302 // The bufferNode will always share tail with the buffer 1303 bufferNode.left = usedBuffer[$ - originalLength - ch.length .. $]; 1304 1305 } 1306 1307 // Save previous value in undo stack 1308 const previousState = snapshot(); 1309 scope (success) pushSnapshot(previousState); 1310 1311 // Insert the text by replacing the old node, if present 1312 value = value.replace(caretIndex - originalLength, caretIndex, Rope(bufferNode)); 1313 assert(value.isBalanced); 1314 1315 // Move the caret 1316 caretIndex = caretIndex + ch.length; 1317 updateCaretPosition(); 1318 horizontalAnchor = caretPosition.x; 1319 1320 } 1321 1322 /// ditto 1323 void push(Rope text) { 1324 1325 // Save previous value in undo stack 1326 const previousState = snapshot(); 1327 scope (success) pushSnapshot(previousState); 1328 1329 // If selection is active, overwrite the selection 1330 if (isSelecting) { 1331 1332 // Override with the character 1333 selectedValue = text; 1334 clearSelection(); 1335 1336 } 1337 1338 // Insert the character before caret 1339 else if (valueBeforeCaret.length) { 1340 1341 valueBeforeCaret = valueBeforeCaret ~ text; 1342 touch(); 1343 1344 } 1345 1346 else { 1347 1348 valueBeforeCaret = text; 1349 touch(); 1350 1351 } 1352 1353 updateCaretPosition(); 1354 horizontalAnchor = caretPosition.x; 1355 1356 } 1357 1358 @("TextInput.push reuses the text buffer; creates undo entries regardless of buffer") 1359 unittest { 1360 1361 auto root = textInput(); 1362 root.value = "Ho"; 1363 1364 root.caretIndex = 1; 1365 root.push("e"); 1366 assert(root.value.byNode.equal(["H", "e", "o"])); 1367 1368 root.push("n"); 1369 1370 assert(root.value.byNode.equal(["H", "en", "o"])); 1371 1372 root.push("l"); 1373 assert(root.value.byNode.equal(["H", "enl", "o"])); 1374 1375 // Create enough text to fill the buffer 1376 // A new node should be created as a result 1377 auto bufferFiller = 'A'.repeat(root.freeBuffer.length).array; 1378 1379 root.push(bufferFiller); 1380 assert(root.value.byNode.equal(["H", "enl", bufferFiller, "o"])); 1381 1382 // Undo all pushes until the initial fill 1383 root.undo(); 1384 assert(root.value == "Ho"); 1385 assert(root.valueBeforeCaret == "H"); 1386 1387 // Undo will not clear the initial value 1388 root.undo(); 1389 assert(root.value == "Ho"); 1390 assert(root.valueBeforeCaret == "H"); 1391 1392 // The above undo does not add a new redo stack entry; effectively, this redo cancels both undo actions above 1393 root.redo(); 1394 assert(root.value.byNode.equal(["H", "enl", bufferFiller, "o"])); 1395 1396 } 1397 1398 /// Start a new line 1399 @(FluidInputAction.breakLine) 1400 bool breakLine() { 1401 1402 if (!multiline) return false; 1403 1404 auto snap = snapshot(); 1405 push('\n'); 1406 forcePushSnapshot(snap); 1407 1408 return true; 1409 1410 } 1411 1412 /// Submit the input. 1413 @(FluidInputAction.submit) 1414 void submit() { 1415 1416 import std.sumtype : match; 1417 1418 // breakLine has higher priority, stop if it's active 1419 if (multiline && tree.isActive!(FluidInputAction.breakLine)) return; 1420 1421 // Run the callback 1422 if (submitted) submitted(); 1423 1424 } 1425 1426 /// Erase last word before the caret, or the first word after. 1427 /// 1428 /// Parms: 1429 /// forward = If true, delete the next word. If false, delete the previous. 1430 void chopWord(bool forward = false) { 1431 1432 import std.uni; 1433 import std.range; 1434 1435 // Save previous value in undo stack 1436 const previousState = snapshot(); 1437 scope (success) pushSnapshot(previousState); 1438 1439 // Selection active, delete it 1440 if (isSelecting) { 1441 1442 selectedValue = null; 1443 1444 } 1445 1446 // Remove next word 1447 else if (forward) { 1448 1449 // Find the word to delete 1450 const erasedWord = valueAfterCaret.wordFront; 1451 1452 // Remove the word 1453 valueAfterCaret = valueAfterCaret[erasedWord.length .. $]; 1454 1455 } 1456 1457 // Remove previous word 1458 else { 1459 1460 // Find the word to delete 1461 const erasedWord = valueBeforeCaret.wordBack; 1462 1463 // Remove the word 1464 valueBeforeCaret = valueBeforeCaret[0 .. $ - erasedWord.length]; 1465 1466 } 1467 1468 // Update the size of the box 1469 updateSize(); 1470 updateCaretPosition(); 1471 horizontalAnchor = caretPosition.x; 1472 1473 } 1474 1475 /// Remove a word before the caret. 1476 @(FluidInputAction.backspaceWord) 1477 void backspaceWord() { 1478 1479 chopWord(); 1480 touchText(); 1481 1482 } 1483 1484 /// Delete a word in front of the caret. 1485 @(FluidInputAction.deleteWord) 1486 void deleteWord() { 1487 1488 chopWord(true); 1489 touchText(); 1490 1491 } 1492 1493 /// Erase any character preceding the caret, or the next one. 1494 /// Params: 1495 /// forward = If true, removes character after the caret, otherwise removes the one before. 1496 void chop(bool forward = false) { 1497 1498 // Save previous value in undo stack 1499 const previousState = snapshot(); 1500 scope (success) pushSnapshot(previousState); 1501 1502 // Selection active 1503 if (isSelecting) { 1504 1505 selectedValue = null; 1506 1507 } 1508 1509 // Remove next character 1510 else if (forward) { 1511 1512 if (valueAfterCaret == "") return; 1513 1514 const length = valueAfterCaret.decodeFrontStatic.codeLength!char; 1515 1516 valueAfterCaret = valueAfterCaret[length..$]; 1517 1518 } 1519 1520 // Remove previous character 1521 else { 1522 1523 if (valueBeforeCaret == "") return; 1524 1525 const length = valueBeforeCaret.decodeBackStatic.codeLength!char; 1526 1527 valueBeforeCaret = valueBeforeCaret[0..$-length]; 1528 1529 } 1530 1531 // Trigger the callback 1532 touchText(); 1533 1534 // Update the size of the box 1535 updateSize(); 1536 updateCaretPosition(); 1537 horizontalAnchor = caretPosition.x; 1538 1539 } 1540 1541 private { 1542 1543 /// Number of clicks performed within short enough time from each other. First click is number 0. 1544 int _clickCount; 1545 1546 /// Time of the last `press` event, used to enable double click and triple click selection. 1547 SysTime _lastClick; 1548 1549 /// Position of the last click. 1550 Vector2 _lastClickPosition; 1551 1552 } 1553 1554 /// Switch hover selection mode. 1555 /// 1556 /// A single click+hold will use per-character selection. A double click+hold will select whole words, 1557 /// and a triple click+hold will select entire lines. 1558 @(FluidInputAction.press) 1559 protected void press(HoverPointer pointer) { 1560 1561 enum maxDistance = 5; 1562 1563 // To count as repeated, the click must be within the specified double click time, and close enough 1564 // to the original location 1565 const isRepeated = Clock.currTime - _lastClick < io.doubleClickTime /* TODO GET RID */ 1566 && distance(pointer.position, _lastClickPosition) < maxDistance; 1567 1568 // Count repeated clicks 1569 _clickCount = isRepeated 1570 ? _clickCount + 1 1571 : 0; 1572 1573 // Register the click 1574 _lastClick = Clock.currTime; 1575 _lastClickPosition = pointer.position; 1576 1577 } 1578 1579 /// Update selection using the mouse. 1580 @(FluidInputAction.press, WhileHeld) 1581 protected void pressAndHold(HoverPointer pointer) { 1582 1583 // Move the caret with the mouse 1584 caretToPointer(pointer); 1585 moveOrClearSelection(); 1586 1587 // Turn on selection from now on, disable it once released 1588 selectionMovement = true; 1589 1590 // Multi-click not supported 1591 if (pointer.clickCount == 0) return; 1592 1593 final switch ((pointer.clickCount + 2) % 3) { 1594 1595 // First click, merely move the caret while selecting 1596 case 0: break; 1597 1598 // Second click, select the word surrounding the caret 1599 case 1: 1600 selectWord(); 1601 break; 1602 1603 // Third click, select whole line 1604 case 2: 1605 selectLine(); 1606 break; 1607 1608 } 1609 1610 } 1611 1612 protected override void mouseImpl() { 1613 1614 // The new I/O system will call the other overload. 1615 // Call it as a polyfill for the old system. 1616 if (!hoverIO) { 1617 1618 // Not holding, disable selection 1619 if (!tree.isMouseDown!(FluidInputAction.press)) { 1620 selectionMovement = false; 1621 return; 1622 } 1623 1624 HoverPointer pointer; 1625 pointer.position = io.mousePosition; 1626 pointer.clickCount = _clickCount + 1; 1627 1628 if (tree.isMouseActive!(FluidInputAction.press)) { 1629 press(pointer); 1630 } 1631 1632 pointer.clickCount = _clickCount + 1; 1633 1634 pressAndHold(pointer); 1635 1636 } 1637 1638 } 1639 1640 protected override bool hoverImpl(HoverPointer) { 1641 1642 // Disable selection when not holding 1643 if (hoverIO) { 1644 selectionMovement = false; 1645 } 1646 return false; 1647 1648 } 1649 1650 @("Legacy: mouse selections works correctly across lines (migrated)") 1651 unittest { 1652 1653 import std.math : isClose; 1654 1655 auto io = new HeadlessBackend; 1656 auto theme = nullTheme.derive( 1657 rule!TextInput( 1658 Rule.selectionBackgroundColor = color("#02a"), 1659 Rule.fontSize = 14.pt, 1660 ), 1661 ); 1662 auto root = textInput(.multiline, theme); 1663 1664 root.io = io; 1665 root.value = "Line one\nLine two\n\nLine four"; 1666 root.focus(); 1667 root.draw(); 1668 1669 auto lineHeight = root.style.getTypeface.lineHeight; 1670 1671 // Move the caret to second line 1672 root.caretIndex = "Line one\nLin".length; 1673 root.updateCaretPosition(); 1674 1675 const middle = root._inner.start + root.caretPosition; 1676 const top = middle - Vector2(0, lineHeight); 1677 const blank = middle + Vector2(0, lineHeight); 1678 const bottom = middle + Vector2(0, lineHeight * 2); 1679 1680 { 1681 1682 // Press, and move the mouse around 1683 io.nextFrame(); 1684 io.mousePosition = middle; 1685 io.press(); 1686 root.draw(); 1687 1688 // Move it to top row 1689 io.nextFrame(); 1690 io.mousePosition = top; 1691 root.draw(); 1692 1693 assert(root.selectedValue == "e one\nLin"); 1694 assert(root.selectionStart > root.selectionEnd); 1695 1696 // Move it to bottom row 1697 io.nextFrame(); 1698 io.mousePosition = bottom; 1699 root.draw(); 1700 1701 assert(root.selectedValue == "e two\n\nLin"); 1702 assert(root.selectionStart < root.selectionEnd); 1703 1704 // And to the blank line 1705 io.nextFrame(); 1706 io.mousePosition = blank; 1707 root.draw(); 1708 1709 assert(root.selectedValue == "e two\n"); 1710 assert(root.selectionStart < root.selectionEnd); 1711 1712 } 1713 1714 { 1715 1716 // Double click 1717 io.mousePosition = middle; 1718 root._lastClick = SysTime.init; 1719 1720 foreach (i; 0..2) { 1721 1722 io.nextFrame(); 1723 io.release(); 1724 root.draw(); 1725 1726 io.nextFrame(); 1727 io.press(); 1728 root.draw(); 1729 1730 } 1731 1732 assert(root.selectedValue == "Line"); 1733 assert(root.selectionStart < root.selectionEnd); 1734 1735 // Move it to top row 1736 io.nextFrame(); 1737 io.mousePosition = top; 1738 root.draw(); 1739 1740 assert(root.selectedValue == "Line one\nLine"); 1741 assert(root.selectionStart > root.selectionEnd); 1742 1743 // Move it to bottom row 1744 io.nextFrame(); 1745 io.mousePosition = bottom; 1746 root.draw(); 1747 1748 assert(root.selectedValue == "Line two\n\nLine"); 1749 assert(root.selectionStart < root.selectionEnd); 1750 1751 // And to the blank line 1752 io.nextFrame(); 1753 io.mousePosition = blank; 1754 root.draw(); 1755 1756 assert(root.selectedValue == "Line two\n"); 1757 assert(root.selectionStart < root.selectionEnd); 1758 1759 } 1760 1761 { 1762 1763 // Triple 1764 io.mousePosition = middle; 1765 root._lastClick = SysTime.init; 1766 1767 foreach (i; 0..3) { 1768 1769 io.nextFrame(); 1770 io.release(); 1771 root.draw(); 1772 1773 io.nextFrame(); 1774 io.press(); 1775 root.draw(); 1776 1777 } 1778 1779 assert(root.selectedValue == "Line two"); 1780 assert(root.selectionStart < root.selectionEnd); 1781 1782 // Move it to top row 1783 io.nextFrame(); 1784 io.mousePosition = top; 1785 root.draw(); 1786 1787 assert(root.selectedValue == "Line one\nLine two"); 1788 assert(root.selectionStart > root.selectionEnd); 1789 1790 // Move it to bottom row 1791 io.nextFrame(); 1792 io.mousePosition = bottom; 1793 root.draw(); 1794 1795 assert(root.selectedValue == "Line two\n\nLine four"); 1796 assert(root.selectionStart < root.selectionEnd); 1797 1798 // And to the blank line 1799 io.nextFrame(); 1800 io.mousePosition = blank; 1801 root.draw(); 1802 1803 assert(root.selectedValue == "Line two\n"); 1804 assert(root.selectionStart < root.selectionEnd); 1805 1806 } 1807 1808 } 1809 1810 protected override void scrollImpl(Vector2 value) { 1811 1812 const speed = ScrollInput.scrollSpeed; 1813 const move = speed * value.x; 1814 1815 scroll = scroll + move; 1816 1817 } 1818 1819 protected override bool scrollImpl(HoverPointer pointer) { 1820 scroll = scroll + pointer.scroll.x; 1821 return true; 1822 } 1823 1824 Rectangle shallowScrollTo(const(Node) child, Rectangle parentBox, Rectangle childBox) { 1825 1826 return childBox; 1827 1828 } 1829 1830 /// Get line in the input by a byte index. 1831 /// Returns: A rope slice with the line containing the given index. 1832 Rope lineByIndex(KeepTerminator keepTerminator = No.keepTerminator)(size_t index) const { 1833 1834 return value.lineByIndex!keepTerminator(index); 1835 1836 } 1837 1838 /// Update a line with given byte index. 1839 const(char)[] lineByIndex(size_t index, const(char)[] value) { 1840 1841 lineByIndex(index, Rope(value)); 1842 return value; 1843 1844 } 1845 1846 /// ditto 1847 Rope lineByIndex(size_t index, Rope newValue) { 1848 1849 import std.utf; 1850 import fluid.typeface; 1851 1852 const backLength = Typeface.lineSplitter(value[0..index].retro).front.byChar.walkLength; 1853 const frontLength = Typeface.lineSplitter(value[index..$]).front.byChar.walkLength; 1854 const start = index - backLength; 1855 const end = index + frontLength; 1856 size_t[2] selection = [selectionStart, selectionEnd]; 1857 1858 // Combine everything on the same line, before and after the caret 1859 value = value.replace(start, end, newValue); 1860 1861 // Caret/selection needs updating 1862 foreach (i, ref caret; selection) { 1863 1864 const diff = newValue.length - end + start; 1865 1866 if (caret < start) continue; 1867 1868 // Move the caret to the start or end, depending on its type 1869 if (caret <= end) { 1870 1871 // Selection start moves to the beginning 1872 // But only if selection is active 1873 if (i == 0 && isSelecting) 1874 caret = start; 1875 1876 // End moves to the end 1877 else 1878 caret = start + newValue.length; 1879 1880 } 1881 1882 // Offset the caret 1883 else caret += diff; 1884 1885 } 1886 1887 // Update the carets 1888 selectionStart = selection[0]; 1889 selectionEnd = selection[1]; 1890 1891 return newValue; 1892 1893 } 1894 1895 /// Get the index of the start or end of the line — from index of any character on the same line. 1896 size_t lineStartByIndex(size_t index) { 1897 1898 return value.lineStartByIndex(index); 1899 1900 } 1901 1902 /// ditto 1903 size_t lineEndByIndex(size_t index) { 1904 1905 return value.lineEndByIndex(index); 1906 1907 } 1908 1909 /// Get the current line 1910 Rope caretLine() { 1911 1912 return value.lineByIndex(caretIndex); 1913 1914 } 1915 1916 /// Change the current line. Moves the cursor to the end of the newly created line. 1917 const(char)[] caretLine(const(char)[] newValue) { 1918 1919 return lineByIndex(caretIndex, newValue); 1920 1921 } 1922 1923 /// ditto 1924 Rope caretLine(Rope newValue) { 1925 1926 return lineByIndex(caretIndex, newValue); 1927 1928 } 1929 1930 /// Get the column the given index (or the cursor, if omitted) is on. 1931 /// Returns: 1932 /// Return value depends on the type fed into the function. `column!dchar` will use characters and `column!char` 1933 /// will use bytes. The type does not have effect on the input index. 1934 ptrdiff_t column(Chartype)(ptrdiff_t index) { 1935 1936 return value.column!Chartype(index); 1937 1938 } 1939 1940 /// ditto 1941 ptrdiff_t column(Chartype)() { 1942 1943 return column!Chartype(caretIndex); 1944 1945 } 1946 1947 /// Iterate on each line in an interval. 1948 auto eachLineByIndex(ptrdiff_t start, ptrdiff_t end) { 1949 1950 struct LineIterator { 1951 1952 TextInput input; 1953 ptrdiff_t index; 1954 ptrdiff_t end; 1955 1956 private Rope front; 1957 private ptrdiff_t nextLine; 1958 1959 alias SetLine = void delegate(Rope line) @safe; 1960 1961 int opApply(scope int delegate(size_t startIndex, ref Rope line) @safe yield) { 1962 1963 while (index <= end) { 1964 1965 const line = input.value.lineByIndex!(Yes.keepTerminator)(index); 1966 1967 // Get index of the next line 1968 const lineStart = index - input.column!char(index); 1969 nextLine = lineStart + line.length; 1970 1971 // Output the line 1972 const originalFront = front = line[].chomp; 1973 auto stop = yield(lineStart, front); 1974 1975 // Update indices in case the line has changed 1976 if (front !is originalFront) { 1977 setLine(originalFront, front); 1978 } 1979 1980 // Stop if requested 1981 if (stop) return stop; 1982 1983 // Stop if reached the end of string 1984 if (index == nextLine) return 0; 1985 if (line.length == originalFront.length) return 0; 1986 1987 // Move to the next line 1988 index = nextLine; 1989 1990 } 1991 1992 return 0; 1993 1994 } 1995 1996 int opApply(scope int delegate(ref Rope line) @safe yield) { 1997 1998 foreach (index, ref line; this) { 1999 2000 if (auto stop = yield(line)) return stop; 2001 2002 } 2003 2004 return 0; 2005 2006 } 2007 2008 /// Replace the current line with a new one. 2009 private void setLine(Rope oldLine, Rope line) @safe { 2010 2011 const lineStart = index - input.column!char(index); 2012 2013 // Get the size of the line terminator 2014 const lineTerminatorLength = nextLine - lineStart - oldLine.length; 2015 2016 // Update the line 2017 input.lineByIndex(index, line); 2018 index = lineStart + line.length; 2019 end += line.length - oldLine.length; 2020 2021 // Add the terminator 2022 nextLine = index + lineTerminatorLength; 2023 2024 assert(line == front); 2025 assert(nextLine >= index); 2026 assert(nextLine <= input.value.length); 2027 2028 } 2029 2030 } 2031 2032 return LineIterator(this, start, end); 2033 2034 } 2035 2036 /// Return each line containing the selection. 2037 auto eachSelectedLine() { 2038 2039 return eachLineByIndex(selectionLowIndex, selectionHighIndex); 2040 2041 } 2042 2043 /// Open the input's context menu. 2044 @(FluidInputAction.contextMenu) 2045 void openContextMenu(HoverPointer pointer) { 2046 2047 // Move the caret to the pointer's position 2048 if (!isSelecting) 2049 caretToPointer(pointer); 2050 2051 } 2052 2053 /// Open the input's context menu. 2054 @(FluidInputAction.contextMenu) 2055 void openContextMenu() { 2056 2057 const anchor = focusBoxImpl(_inner); 2058 2059 // Spawn the popup at caret position 2060 if (overlayIO) { 2061 overlayIO.addPopup(contextMenu, anchor); 2062 } 2063 else { 2064 contextMenu.anchor = anchor; 2065 tree.spawnPopup(contextMenu); 2066 } 2067 2068 } 2069 2070 /// Remove a character before the caret. Same as `chop`. 2071 @(FluidInputAction.backspace) 2072 void backspace() { 2073 2074 chop(); 2075 2076 } 2077 2078 /// Delete one character in front of the cursor. 2079 @(FluidInputAction.deleteChar) 2080 void deleteChar() { 2081 2082 chop(true); 2083 2084 } 2085 2086 /// Clear the value of this input field, making it empty. 2087 void clear() 2088 out(; isEmpty) 2089 do { 2090 2091 // Remove the value 2092 value = null; 2093 2094 clearSelection(); 2095 updateCaretPosition(); 2096 horizontalAnchor = caretPosition.x; 2097 2098 } 2099 2100 /// Select the word surrounding the cursor. If selection is active, expands selection to cover words. 2101 void selectWord() { 2102 2103 enum excludeWhite = true; 2104 2105 const isLow = selectionStart <= selectionEnd; 2106 const low = selectionLowIndex; 2107 const high = selectionHighIndex; 2108 2109 const head = value[0 .. low].wordBack(excludeWhite); 2110 const tail = value[high .. $].wordFront(excludeWhite); 2111 2112 // Move the caret to the end of the word 2113 caretIndex = high + tail.length; 2114 2115 // Set selection to the start of the word 2116 selectionStart = low - head.length; 2117 2118 // Swap them if order is reversed 2119 if (!isLow) swap(_selectionStart, _caretIndex); 2120 2121 touch(); 2122 updateCaretPosition(false); 2123 2124 } 2125 2126 /// Select the whole line the cursor is. 2127 void selectLine() { 2128 2129 const isLow = selectionStart <= selectionEnd; 2130 2131 foreach (index, line; Typeface.lineSplitterIndex(value)) { 2132 2133 const lineStart = index; 2134 const lineEnd = index + line.length; 2135 2136 // Found selection start 2137 if (lineStart <= selectionStart && selectionStart <= lineEnd) { 2138 2139 selectionStart = isLow 2140 ? lineStart 2141 : lineEnd; 2142 2143 } 2144 2145 // Found selection end 2146 if (lineStart <= selectionEnd && selectionEnd <= lineEnd) { 2147 2148 selectionEnd = isLow 2149 ? lineEnd 2150 : lineStart; 2151 2152 2153 } 2154 2155 } 2156 2157 updateCaretPosition(false); 2158 horizontalAnchor = caretPosition.x; 2159 2160 } 2161 2162 /// Move caret to the previous or next character. 2163 @(FluidInputAction.previousChar, FluidInputAction.nextChar) 2164 protected void previousOrNextChar(FluidInputAction action) { 2165 2166 const forward = action == FluidInputAction.nextChar; 2167 2168 // Terminating selection 2169 if (isSelecting && !selectionMovement) { 2170 2171 // Move to either end of the selection 2172 caretIndex = forward 2173 ? selectionHighIndex 2174 : selectionLowIndex; 2175 clearSelection(); 2176 2177 } 2178 2179 // Move to next character 2180 else if (forward) { 2181 2182 if (valueAfterCaret == "") return; 2183 2184 const length = valueAfterCaret.decodeFrontStatic.codeLength!char; 2185 2186 caretIndex = caretIndex + length; 2187 2188 } 2189 2190 // Move to previous character 2191 else { 2192 2193 if (valueBeforeCaret == "") return; 2194 2195 const length = valueBeforeCaret.decodeBackStatic.codeLength!char; 2196 2197 caretIndex = caretIndex - length; 2198 2199 } 2200 2201 updateCaretPosition(true); 2202 horizontalAnchor = caretPosition.x; 2203 2204 } 2205 2206 /// Move caret to the previous or next word. 2207 @(FluidInputAction.previousWord, FluidInputAction.nextWord) 2208 protected void previousOrNextWord(FluidInputAction action) { 2209 2210 // Previous word 2211 if (action == FluidInputAction.previousWord) { 2212 2213 caretIndex = caretIndex - valueBeforeCaret.wordBack.length; 2214 2215 } 2216 2217 // Next word 2218 else { 2219 2220 caretIndex = caretIndex + valueAfterCaret.wordFront.length; 2221 2222 } 2223 2224 updateCaretPosition(true); 2225 moveOrClearSelection(); 2226 horizontalAnchor = caretPosition.x; 2227 2228 } 2229 2230 /// Move the caret to the previous or next line. 2231 @(FluidInputAction.previousLine, FluidInputAction.nextLine) 2232 protected void previousOrNextLine(FluidInputAction action) { 2233 2234 auto search = Vector2(horizontalAnchor, caretPosition.y); 2235 2236 // Next line 2237 if (action == FluidInputAction.nextLine) { 2238 search.y += lineHeight; 2239 } 2240 2241 // Previous line 2242 else { 2243 search.y -= lineHeight; 2244 } 2245 2246 caretTo(search); 2247 updateCaretPosition(horizontalAnchor < 1); 2248 moveOrClearSelection(); 2249 2250 } 2251 2252 /// Move the caret to the given screen position (viewport space). 2253 /// Params: 2254 /// position = Position in the screen to move the cursor to. 2255 void caretTo(Vector2 position) { 2256 2257 caretIndex = nearestCharacter(position); 2258 2259 } 2260 2261 /// Move the caret to mouse position. 2262 void caretToMouse() { 2263 2264 caretTo(io.mousePosition - _inner.start); 2265 updateCaretPosition(false); 2266 horizontalAnchor = caretPosition.x; 2267 2268 } 2269 2270 /// ditto 2271 void caretToPointer(HoverPointer pointer) { 2272 2273 caretTo(pointer.position - _inner.start); 2274 updateCaretPosition(false); 2275 horizontalAnchor = caretPosition.x; 2276 2277 } 2278 2279 /// Move the caret to the beginning of the line. This function perceives the line visually, so if the text wraps, it 2280 /// will go to the beginning of the visible line, instead of the hard line break. 2281 @(FluidInputAction.toLineStart) 2282 void caretToLineStart() { 2283 2284 const search = Vector2(0, caretPosition.y); 2285 2286 caretTo(search); 2287 updateCaretPosition(true); 2288 moveOrClearSelection(); 2289 horizontalAnchor = caretPosition.x; 2290 2291 } 2292 2293 /// Move the caret to the end of the line. 2294 @(FluidInputAction.toLineEnd) 2295 void caretToLineEnd() { 2296 2297 const search = Vector2(float.infinity, caretPosition.y); 2298 2299 caretTo(search); 2300 updateCaretPosition(false); 2301 moveOrClearSelection(); 2302 horizontalAnchor = caretPosition.x; 2303 2304 } 2305 2306 /// Move the caret to the beginning of the input 2307 @(FluidInputAction.toStart) 2308 void caretToStart() { 2309 2310 caretIndex = 0; 2311 updateCaretPosition(true); 2312 moveOrClearSelection(); 2313 horizontalAnchor = caretPosition.x; 2314 2315 } 2316 2317 /// Move the caret to the end of the input 2318 @(FluidInputAction.toEnd) 2319 void caretToEnd() { 2320 2321 caretIndex = value.length; 2322 updateCaretPosition(false); 2323 moveOrClearSelection(); 2324 horizontalAnchor = caretPosition.x; 2325 2326 } 2327 2328 /// Select all text 2329 @(FluidInputAction.selectAll) 2330 void selectAll() { 2331 2332 selectionMovement = true; 2333 scope (exit) selectionMovement = false; 2334 2335 _selectionStart = 0; 2336 caretToEnd(); 2337 2338 } 2339 2340 /// Begin or continue selection using given movement action. 2341 /// 2342 /// Use `selectionStart` and `selectionEnd` to define selection boundaries manually. 2343 @( 2344 FluidInputAction.selectPreviousChar, 2345 FluidInputAction.selectNextChar, 2346 FluidInputAction.selectPreviousWord, 2347 FluidInputAction.selectNextWord, 2348 FluidInputAction.selectPreviousLine, 2349 FluidInputAction.selectNextLine, 2350 FluidInputAction.selectToLineStart, 2351 FluidInputAction.selectToLineEnd, 2352 FluidInputAction.selectToStart, 2353 FluidInputAction.selectToEnd, 2354 ) 2355 protected void select(FluidInputAction action) { 2356 2357 selectionMovement = true; 2358 scope (exit) selectionMovement = false; 2359 2360 // Start selection 2361 if (!isSelecting) 2362 selectionStart = caretIndex; 2363 2364 with (FluidInputAction) switch (action) { 2365 case selectPreviousChar: 2366 runInputAction!previousChar; 2367 break; 2368 case selectNextChar: 2369 runInputAction!nextChar; 2370 break; 2371 case selectPreviousWord: 2372 runInputAction!previousWord; 2373 break; 2374 case selectNextWord: 2375 runInputAction!nextWord; 2376 break; 2377 case selectPreviousLine: 2378 runInputAction!previousLine; 2379 break; 2380 case selectNextLine: 2381 runInputAction!nextLine; 2382 break; 2383 case selectToLineStart: 2384 runInputAction!toLineStart; 2385 break; 2386 case selectToLineEnd: 2387 runInputAction!toLineEnd; 2388 break; 2389 case selectToStart: 2390 runInputAction!toStart; 2391 break; 2392 case selectToEnd: 2393 runInputAction!toEnd; 2394 break; 2395 default: 2396 assert(false, "Invalid action"); 2397 } 2398 2399 } 2400 2401 /// Cut selected text to clipboard, clearing the selection. 2402 @(FluidInputAction.cut) 2403 void cut() { 2404 2405 auto snap = snapshot(); 2406 copy(); 2407 pushSnapshot(snap); 2408 selectedValue = null; 2409 2410 } 2411 2412 /// Copy selected text to clipboard. 2413 @(FluidInputAction.copy) 2414 void copy() { 2415 2416 import std.conv : text; 2417 2418 if (!isSelecting) return; 2419 2420 if (clipboardIO) { 2421 clipboardIO.writeClipboard = selectedValue.toString(); 2422 } 2423 else { 2424 io.clipboard = selectedValue.toString(); 2425 } 2426 2427 } 2428 2429 /// Paste text from clipboard. 2430 @(FluidInputAction.paste) 2431 void paste() { 2432 2433 auto snap = snapshot(); 2434 2435 // New I/O 2436 if (clipboardIO) { 2437 2438 char[1024] buffer; 2439 int offset; 2440 2441 // Read text 2442 while (true) { 2443 2444 // Push the text 2445 if (auto text = clipboardIO.readClipboard(buffer, offset)) { 2446 push(text); 2447 } 2448 else break; 2449 2450 } 2451 2452 } 2453 2454 // Old clipboard 2455 else { 2456 push(io.clipboard); 2457 } 2458 2459 forcePushSnapshot(snap); 2460 2461 } 2462 2463 /// Clear the undo/redo action history. 2464 /// 2465 /// Calling this will erase both the undo and redo stack, making it impossible to restore any changes made in prior, 2466 /// through means such as Ctrl+Z and Ctrl+Y. 2467 void clearHistory() { 2468 2469 _undoStack.clear(); 2470 _redoStack.clear(); 2471 2472 } 2473 2474 /// Push the given state snapshot (value, caret & selection) into the undo stack. Refuses to push if the current 2475 /// state can be merged with it, unless `forcePushSnapshot` is used. 2476 /// 2477 /// A snapshot pushed through `forcePushSnapshot` will break continuity — it will not be merged with any other 2478 /// snapshot. 2479 void pushSnapshot(HistoryEntry entry) { 2480 2481 // Compare against current state, so it can be dismissed if it's too similar 2482 auto currentState = snapshot(); 2483 currentState.setPreviousEntry(entry); 2484 2485 // Mark as continuous, so runs of similar characters can be merged together 2486 scope (success) _isContinuous = true; 2487 2488 // No change was made, ignore 2489 if (currentState.diff.isSame()) return; 2490 2491 // Current state is compatible, ignore 2492 if (entry.isContinuous && entry.canMergeWith(currentState)) return; 2493 2494 // Push state 2495 forcePushSnapshot(entry); 2496 2497 } 2498 2499 /// ditto 2500 void forcePushSnapshot(HistoryEntry entry) { 2501 2502 // Break continuity 2503 _isContinuous = false; 2504 2505 // Ignore if the last entry is identical 2506 if (!_undoStack.empty && _undoStack.back == entry) return; 2507 2508 // Truncate the history to match the index, insert the current value. 2509 _undoStack.insertBack(entry); 2510 2511 // Clear the redo stack 2512 _redoStack.clear(); 2513 2514 } 2515 2516 /// Produce a snapshot for the current state. Returns the snapshot. 2517 protected HistoryEntry snapshot() const { 2518 2519 auto entry = HistoryEntry(value, selectionStart, selectionEnd, _isContinuous); 2520 2521 // Get previous entry in the history 2522 if (!_undoStack.empty) 2523 entry.setPreviousEntry(_undoStack.back.value); 2524 2525 return entry; 2526 2527 } 2528 2529 /// Restore state from snapshot 2530 protected HistoryEntry snapshot(HistoryEntry entry) { 2531 2532 value = entry.value; 2533 selectSlice(entry.selectionStart, entry.selectionEnd); 2534 2535 return entry; 2536 2537 } 2538 2539 /// Restore the last value in history. 2540 @(FluidInputAction.undo) 2541 void undo() { 2542 2543 // Nothing to undo 2544 if (_undoStack.empty) return; 2545 2546 // Push the current state to redo stack 2547 _redoStack.insertBack(snapshot); 2548 2549 // Restore the value 2550 this.snapshot = _undoStack.back; 2551 _undoStack.removeBack; 2552 2553 } 2554 2555 /// Perform the last undone action again. 2556 @(FluidInputAction.redo) 2557 void redo() { 2558 2559 // Nothing to redo 2560 if (_redoStack.empty) return; 2561 2562 // Push the current state to undo stack 2563 _undoStack.insertBack(snapshot); 2564 2565 // Restore the value 2566 this.snapshot = _redoStack.back; 2567 _redoStack.removeBack; 2568 2569 } 2570 2571 } 2572 2573 /// `wordFront` and `wordBack` get the word at the beginning or end of given string, respectively. 2574 /// 2575 /// A word is a streak of consecutive characters — non-whitespace, either all alphanumeric or all not — followed by any 2576 /// number of whitespace. 2577 /// 2578 /// Params: 2579 /// text = Text to scan for the word. 2580 /// excludeWhite = If true, whitespace will not be included in the word. 2581 T wordFront(T)(T text, bool excludeWhite = false) { 2582 2583 size_t length; 2584 2585 T result() { return text[0..length]; } 2586 T remaining() { return text[length..$]; } 2587 2588 while (remaining != "") { 2589 2590 // Get the first character 2591 const lastChar = remaining.decodeFrontStatic; 2592 2593 // Exclude white characters if enabled 2594 if (excludeWhite && lastChar.isWhite) break; 2595 2596 length += lastChar.codeLength!(typeof(text[0])); 2597 2598 // Stop if empty 2599 if (remaining == "") break; 2600 2601 const nextChar = remaining.decodeFrontStatic; 2602 2603 // Stop if the next character is a line feed 2604 if (nextChar.only.chomp.empty && !only(lastChar, nextChar).equal("\r\n")) break; 2605 2606 // Continue if the next character is whitespace 2607 // Includes any case where the previous character is followed by whitespace 2608 else if (nextChar.isWhite) continue; 2609 2610 // Stop if whitespace follows a non-white character 2611 else if (lastChar.isWhite) break; 2612 2613 // Stop if the next character has different type 2614 else if (lastChar.isAlphaNum != nextChar.isAlphaNum) break; 2615 2616 } 2617 2618 return result; 2619 2620 } 2621 2622 /// ditto 2623 T wordBack(T)(T text, bool excludeWhite = false) { 2624 2625 size_t length = text.length; 2626 2627 T result() { return text[length..$]; } 2628 T remaining() { return text[0..length]; } 2629 2630 while (remaining != "") { 2631 2632 // Get the first character 2633 const lastChar = remaining.decodeBackStatic; 2634 2635 // Exclude white characters if enabled 2636 if (excludeWhite && lastChar.isWhite) break; 2637 2638 length -= lastChar.codeLength!(typeof(text[0])); 2639 2640 // Stop if empty 2641 if (remaining == "") break; 2642 2643 const nextChar = remaining.decodeBackStatic; 2644 2645 // Stop if the character is a line feed 2646 if (lastChar.only.chomp.empty && !only(nextChar, lastChar).equal("\r\n")) break; 2647 2648 // Continue if the current character is whitespace 2649 // Inverse to `wordFront` 2650 else if (lastChar.isWhite) continue; 2651 2652 // Stop if whitespace follows a non-white character 2653 else if (nextChar.isWhite) break; 2654 2655 // Stop if the next character has different type 2656 else if (lastChar.isAlphaNum != nextChar.isAlphaNum) break; 2657 2658 } 2659 2660 return result; 2661 2662 } 2663 2664 /// `decodeFront` and `decodeBack` variants that do not mutate the range 2665 private dchar decodeFrontStatic(T)(T range) @trusted { 2666 2667 return range.decodeFront; 2668 2669 } 2670 2671 /// ditto 2672 private dchar decodeBackStatic(T)(T range) @trusted { 2673 2674 return range.decodeBack; 2675 2676 }