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