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