1 ///
2 module fluid.text_input;
3 
4 import std.string;
5 
6 import fluid.node;
7 import fluid.text;
8 import fluid.input;
9 import fluid.label;
10 import fluid.style;
11 import fluid.utils;
12 import fluid.scroll;
13 import fluid.backend;
14 import fluid.structs;
15 
16 alias textInput = simpleConstructor!TextInput;
17 
18 @safe:
19 
20 /// Text input field.
21 ///
22 /// Styles: $(UL
23 ///     $(LI `style` = Default style for the input.)
24 ///     $(LI `focusStyle` = Style for when the input is focused.)
25 ///     $(LI `emptyStyle` = Style for when the input is empty, i.e. the placeholder is visible. Text should usually be
26 ///         grayed out.)
27 /// )
28 class TextInput : InputNode!Node {
29 
30     mixin defineStyles!(
31         "emptyStyle", q{ style },
32     );
33     mixin implHoveredRect;
34     mixin enableInputActions;
35 
36     /// Time in seconds between changes in cursor visibility.
37     static immutable float blinkTime = 1;
38 
39     public {
40 
41         /// Size of the field.
42         auto size = Vector2(200, 0);
43 
44         /// Value of the field.
45         string value;
46 
47         /// A placeholder text for the field, displayed when the field is empty. Style using `emptyStyle`.
48         string placeholder;
49 
50         deprecated("multiline was never supported and will be deleted in 0.7.0") {
51 
52             bool multiline() const { return false; }
53             bool multiline(bool) { return false; }
54 
55         }
56 
57     }
58 
59     private {
60 
61         /// Underlying label controlling the content. Needed to properly adjust it to scroll.
62         Scrollable!(TextImpl, "true") contentLabel;
63 
64     }
65 
66     deprecated("Use this(NodeParams, string, void delegate() @safe submitted) instead") {
67 
68         static foreach (index; 0 .. BasicNodeParamLength) {
69 
70             /// Create a text input.
71             /// Params:
72             ///     sup         = Node parameters.
73             ///     placeholder = Placeholder text for the field.
74             ///     submitted   = Callback for when the field is submitted.
75             this(BasicNodeParam!index sup, string placeholder = "", void delegate() @trusted submitted = null) {
76 
77                 super(NodeParams(sup));
78                 this.placeholder = placeholder;
79                 this.submitted = submitted;
80 
81                 // Create the label
82                 this.contentLabel = new typeof(contentLabel)(NodeParams(.layout!(1, "fill")), "");
83 
84                 with (this.contentLabel) {
85 
86                     // Make the scrollbar invisible
87                     scrollBar.disable();
88                     scrollBar.width = 0;
89                     // Note: We're not hiding the scrollbar, so it may adjust used values to the size of the input
90 
91                     disableWrap();
92                     ignoreMouse = true;
93 
94                 }
95 
96             }
97 
98         }
99 
100     }
101 
102     /// Create a text input.
103     /// Params:
104     ///     params      = Node parameters.
105     ///     placeholder = Placeholder text for the field.
106     ///     submitted   = Callback for when the field is submitted.
107     this(NodeParams params, string placeholder = "", void delegate() @trusted submitted = null) {
108 
109         super(params);
110         this.placeholder = placeholder;
111         this.submitted = submitted;
112 
113         // Create the label
114         this.contentLabel = new typeof(contentLabel)(NodeParams(.layout!(1, "fill")), "");
115 
116         with (this.contentLabel) {
117 
118             // Make the scrollbar invisible
119             scrollBar.disable();
120             scrollBar.width = 0;
121             // Note: We're not hiding the scrollbar, so it may adjust used values to the size of the input
122 
123             disableWrap();
124             ignoreMouse = true;
125 
126         }
127 
128     }
129 
130     protected override void resizeImpl(Vector2 area) {
131 
132         import std.algorithm : max;
133 
134         // Set the size
135         minSize = size;
136 
137         // Set height to at least the font size
138         minSize.y = max(minSize.y, style.font.lineHeight);
139 
140         // Set the label text
141         contentLabel.text = (value == "") ? placeholder : value;
142 
143         // Inherit main style
144         // TODO reuse the hashmap maybe?
145         auto childTheme = theme.makeTheme!q{
146 
147             Label.styleAdd!q{
148 
149                 // Those are already included in our theme, we should remove them
150                 margin = 0;
151                 padding = 0;
152                 border = 0;
153 
154             };
155 
156         };
157 
158         // Resize the label
159         contentLabel.resize(tree, childTheme, Vector2(0, minSize.y));
160 
161     }
162 
163     protected override void drawImpl(Rectangle outer, Rectangle inner) @trusted {
164 
165         // Note: We're drawing the label in `outer` as the presence of the label is meant to be transparent.
166 
167         import std.datetime : Clock;
168         import std.algorithm : min, max;
169 
170         auto style = pickStyle();
171 
172         const scrollOffset = max(0, contentLabel.scrollMax - inner.w);
173 
174         // Fill the background
175         style.drawBackground(tree.io, outer);
176 
177         // Copy the style to the label
178         contentLabel.activeStyle = style;
179 
180         // Set the scroll
181         contentLabel.scroll = cast(size_t) scrollOffset;
182 
183         // Draw the text
184         contentLabel.draw(inner);
185 
186         // Ignore the rest if the node isn't focused
187         if (!isFocused || isDisabledInherited) return;
188 
189         auto timeSecs = Clock.currTime.second;
190 
191         // Add a blinking caret
192         if (timeSecs % (blinkTime*2) < blinkTime) {
193 
194             const lineHeight = style.typeface.lineHeight;
195             const margin = style.typeface.lineHeight / 10f;
196 
197             // Put the caret at the start if the placeholder is shown
198             const textWidth = value.length
199                 ? min(contentLabel.scrollMax, inner.w)
200                 : 0;
201 
202             // Get caret position
203             const end = Vector2(
204                 inner.x + textWidth,
205                 inner.y + inner.height,
206             );
207 
208             // Draw the caret
209             io.drawLine(
210                 end - Vector2(0, lineHeight - margin),
211                 end - Vector2(0, margin),
212                 focusStyle.textColor
213             );
214 
215         }
216 
217     }
218 
219     protected override bool keyboardImpl() @trusted {
220 
221         import std.uni : isAlpha, isWhite;
222         import std.range : back;
223         import std.string : chop;
224 
225         string input;
226 
227         // Get pressed key
228         while (true) {
229 
230             // Read text
231             if (const key = io.inputCharacter) {
232 
233                 // Append to char arrays
234                 input ~= cast(dchar) key;
235 
236             }
237 
238             // Stop if there's nothing left
239             else break;
240 
241         }
242 
243         // Typed something
244         if (input.length) {
245 
246             // Update the value
247             value ~= input;
248 
249             // Trigger the callback
250             if (changed) changed();
251 
252             // Update the size of the input
253             updateSize();
254 
255             return true;
256 
257         }
258 
259         return true;
260 
261     }
262 
263     unittest {
264 
265         auto io = new HeadlessBackend;
266         auto root = textInput("placeholder");
267 
268         root.io = io;
269 
270         // Empty text
271         {
272             root.draw();
273 
274             assert(root.value == "");
275             assert(root.contentLabel.text == "placeholder");
276             assert(root.contentLabel.activeStyle is root.emptyStyle);
277         }
278 
279         // Focus the box and input stuff
280         {
281             io.nextFrame;
282             io.inputCharacter("¡Hola, mundo!");
283             root.focus();
284             root.draw();
285 
286             assert(root.value == "¡Hola, mundo!");
287         }
288 
289         // Input stuff
290         {
291             io.nextFrame;
292             root.draw();
293 
294             assert(root.contentLabel.text == "¡Hola, mundo!");
295             assert(root.contentLabel.activeStyle is root.focusStyle);
296         }
297 
298     }
299 
300     /// Submit the input.
301     @(FluidInputAction.submit)
302     protected void _submit() {
303 
304         // Clear focus
305         isFocused = false;
306 
307         // Run the callback
308         if (submitted) submitted();
309 
310     }
311 
312     unittest {
313 
314         int submitted;
315 
316         auto io = new HeadlessBackend;
317         TextInput root;
318 
319         root = textInput("placeholder", delegate {
320             submitted++;
321             assert(root.value == "Hello World");
322         });
323 
324         root.io = io;
325 
326         // Type stuff
327         {
328             root.value = "Hello World";
329             root.focus();
330             root.updateSize();
331             root.draw();
332 
333             assert(submitted == 0);
334             assert(root.value == "Hello World");
335             assert(root.contentLabel.text == "Hello World");
336         }
337 
338         // Submit
339         {
340             io.nextFrame;
341             io.press(KeyboardKey.enter);
342             root.draw();
343 
344             assert(submitted == 1);
345         }
346 
347     }
348 
349     /// Erase last inputted word.
350     @(FluidInputAction.backspaceWord)
351     void chopWord() {
352 
353         import std.uni;
354         import std.range;
355 
356         // Run while there's something to process
357         while (value != "") {
358 
359             // Remove the last character
360             const lastChar = value.back;
361             value = value.chop;
362 
363             // Stop if empty
364             if (value == "") break;
365 
366             {
367 
368                 // Continue if last removed character was whitespace
369                 if (lastChar.isWhite) continue;
370 
371                 // Continue deleting if two last characters were alphanumeric, or neither of them was
372                 if (value.back.isAlphaNum == lastChar.isAlphaNum) continue;
373 
374             }
375 
376             // Break in other cases
377             break;
378 
379         }
380 
381         // Trigger the callback
382         if (changed) changed();
383 
384         // Update the size of the box
385         updateSize();
386 
387     }
388 
389     unittest {
390 
391         auto io = new HeadlessBackend;
392         auto root = textInput();
393 
394         root.io = io;
395 
396         // Type stuff
397         {
398             root.value = "Hello World";
399             root.focus();
400             root.updateSize();
401             root.draw();
402 
403             assert(root.value == "Hello World");
404             assert(root.contentLabel.text == "Hello World");
405         }
406 
407         // Erase a word
408         {
409             io.nextFrame;
410             root.chopWord;
411             root.draw();
412 
413             assert(root.value == "Hello ");
414             assert(root.contentLabel.text == "Hello ");
415             assert(root.contentLabel.activeStyle is root.focusStyle);
416         }
417 
418         // Erase a word
419         {
420             io.nextFrame;
421             root.chopWord;
422             root.draw();
423 
424             assert(root.value == "");
425             assert(root.contentLabel.text == "");
426             assert(root.contentLabel.activeStyle is root.emptyStyle);
427         }
428 
429         // Typing should be disabled while erasing
430         {
431             io.press(KeyboardKey.leftControl);
432             io.press(KeyboardKey.w);
433             io.inputCharacter('w');
434 
435             root.draw();
436 
437             assert(root.value == "");
438             assert(root.contentLabel.text == "");
439             assert(root.contentLabel.activeStyle is root.emptyStyle);
440         }
441 
442     }
443 
444     /// Erase last inputted letter.
445     @(FluidInputAction.backspace)
446     void chop() {
447 
448         // Ignore if the box is empty
449         if (value == "") return;
450 
451         // Remove the last character
452         value = value.chop;
453 
454         // Trigger the callback
455         if (changed) changed();
456 
457         // Update the size of the box
458         updateSize();
459 
460     }
461 
462     unittest {
463 
464         auto io = new HeadlessBackend;
465         auto root = textInput();
466 
467         root.io = io;
468 
469         // Type stuff
470         {
471             root.value = "hello‽";
472             root.focus();
473             root.updateSize();
474             root.draw();
475 
476             assert(root.value == "hello‽");
477             assert(root.contentLabel.text == "hello‽");
478         }
479 
480         // Erase a letter
481         {
482             io.nextFrame;
483             root.chop;
484             root.draw();
485 
486             assert(root.value == "hello");
487             assert(root.contentLabel.text == "hello");
488             assert(root.contentLabel.activeStyle is root.focusStyle);
489         }
490 
491         // Erase a letter
492         {
493             io.nextFrame;
494             root.chop;
495             root.draw();
496 
497             assert(root.value == "hell");
498             assert(root.contentLabel.text == "hell");
499             assert(root.contentLabel.activeStyle is root.focusStyle);
500         }
501 
502         // Typing should be disabled while erasing
503         {
504             io.press(KeyboardKey.backspace);
505             io.inputCharacter("o, world");
506 
507             root.draw();
508 
509             assert(root.value == "hel");
510             assert(root.contentLabel.activeStyle is root.focusStyle);
511         }
512 
513     }
514 
515     override inout(Style) pickStyle() inout {
516 
517         // Disabled
518         if (isDisabledInherited) return disabledStyle;
519 
520         // Empty text (display placeholder)
521         else if (value == "") return emptyStyle;
522 
523         // Focused
524         else if (isFocused) return focusStyle;
525 
526         // Other styles
527         else return super.pickStyle();
528 
529     }
530 
531 }
532 
533 private class TextImpl : Label {
534 
535     mixin DefineStyles!(
536         "activeStyle", q{ style }
537     );
538 
539     this(T...)(T args) {
540 
541         super(args);
542 
543     }
544 
545     // Same as parent, but doesn't draw background
546     override void drawImpl(Rectangle outer, Rectangle inner) {
547 
548         const style = pickStyle();
549         text.draw(style, inner);
550 
551     }
552 
553     override inout(Style) pickStyle() inout {
554 
555         return activeStyle;
556 
557     }
558 
559 }