1 ///
2 module fluid.number_input;
3 
4 import std.ascii;
5 import std.range;
6 import std.traits;
7 import std.algorithm;
8 
9 import fluid.node;
10 import fluid.input;
11 import fluid.utils;
12 import fluid.style;
13 import fluid.backend;
14 import fluid.structs;
15 import fluid.text_input;
16 
17 alias numberInput(T) = simpleConstructor!(NumberInput!T);
18 alias intInput = simpleConstructor!IntInput;
19 alias floatInput = simpleConstructor!FloatInput;
20 
21 alias IntInput = NumberInput!int;
22 alias FloatInput = NumberInput!float;
23 
24 alias numberInputSpinner = simpleConstructor!NumberInputSpinner;
25 
26 
27 @safe:
28 
29 /// Number input field.
30 class NumberInput(T) : AbstractNumberInput {
31 
32     static assert(isNumeric!T, "NumberInput is only compatible with numeric types.");
33 
34     mixin enableInputActions;
35 
36     public {
37 
38         /// Value of the input.
39         T value = 0;
40 
41         /// Step used by the increment/decrement button.
42         T step = 1;
43 
44         /// Minimum and maximum value for the input, inclusive on both ends.
45         static if (isFloatingPoint!T) {
46             T minValue = -T.infinity;
47             T maxValue = +T.infinity;
48         }
49         else {
50             T minValue = T.min;
51             T maxValue = T.max;
52         }
53 
54     }
55 
56     private {
57 
58         /// If true, the expression passed to the input has been modified. The value will be updated as soon as the
59         /// input is submitted or loses focus.
60         bool isDirty;
61 
62     }
63 
64     this(void delegate() @safe changed = null) {
65 
66         super(changed);
67 
68     }
69 
70     this(T value, void delegate() @safe changed = null) {
71 
72         super(changed);
73         this.value = value;
74         this.update();
75 
76     }
77 
78     override void drawImpl(Rectangle outer, Rectangle inner) {
79 
80         auto style = pickStyle();
81 
82         super.drawImpl(outer, inner);
83         spinner.draw(inner);
84 
85         // Re-evaluate the expression if focus was lost
86         if (!isFocused) evaluate();
87 
88     }
89 
90     /// Update the value.
91     protected void evaluate() {
92 
93         // Ignore if clean, no changes were made
94         if (!isDirty) return;
95 
96         // Evaluate the expression
97         evaluateImpl();
98 
99         // Update the text
100         update();
101 
102         // Call change callback
103         if (changed) changed();
104 
105     }
106 
107     private void evaluateImpl() {
108 
109         // TODO handle failure properly, add a warning sign or something, preserve old value
110         this.value = evaluateExpression!T(super.value).value.clamp(minValue, maxValue);
111 
112         // Mark as clean
113         isDirty = false;
114 
115     }
116 
117     private void update() {
118 
119         import std.conv;
120 
121         // Update the textual value
122         super.value = this.value.to!(char[]);
123 
124         // Move the caret
125         caretToEnd();
126 
127         // Resize
128         updateSize();
129 
130     }
131 
132     /// Increase the value by a step.
133     @(FluidInputAction.scrollUp)
134     override void increment() {
135 
136         evaluateImpl();
137         value += step;
138         update();
139         touch();
140         focus();
141 
142         // Call change callback
143         if (changed) changed();
144 
145     }
146 
147     /// Decrease the value by a step.
148     @(FluidInputAction.scrollDown)
149     override void decrement() {
150 
151         evaluateImpl();
152         value -= step;
153         update();
154         touch();
155         focus();
156 
157         // Call change callback
158         if (changed) changed();
159 
160     }
161 
162     override protected void _changed() {
163 
164         // Instead of calling the callback, simply mark the input as dirty
165         isDirty = true;
166 
167     }
168 
169     @(FluidInputAction.submit)
170     override protected void onSubmit() {
171 
172         // Evaluate the expression
173         evaluate();
174 
175         // Submit
176         super.onSubmit();
177 
178     }
179 
180 }
181 
182 ///
183 unittest {
184 
185     // intInput lets the user specify any integer value
186     intInput();
187 
188     // Float input allows floating point values
189     floatInput();
190 
191     // Specify a callback to update other components as the value of this input changes
192     IntInput myInput;
193 
194     myInput = intInput(delegate {
195 
196         int result = myInput.value;
197 
198     });
199 
200 }
201 
202 unittest {
203 
204     int calls;
205 
206     auto io = new HeadlessBackend;
207     auto root = intInput(delegate {
208 
209         calls++;
210 
211     });
212 
213     root.io = io;
214 
215     // First frame: initial state
216     root.focus();
217     root.draw();
218 
219     assert(root.value == 0);
220     assert(root.TextInput.value == "0");
221 
222     // Second frame, type in "10"
223     io.nextFrame();
224     io.inputCharacter("10");
225     root.draw();
226 
227     // Value should remain unchanged
228     assert(calls == 0);
229     assert(root.value == 0);
230     assert(root.TextInput.value.among("010", "10"));
231 
232     // Hit enter to update
233     io.nextFrame;
234     io.press(KeyboardKey.enter);
235     root.draw();
236 
237     assert(calls == 1);
238     assert(root.value == 10);
239     assert(root.TextInput.value == "10");
240 
241     // Test math equations
242     io.nextFrame;
243     io.inputCharacter("+20*5");
244     io.release(KeyboardKey.enter);
245     root.focus();
246     root.draw();
247 
248     assert(calls == 1);
249     assert(root.value == 10);
250     assert(root.TextInput.value == "10+20*5");
251 
252     // Submit the expression
253     io.nextFrame;
254     io.press(KeyboardKey.enter);
255     root.draw();
256 
257     assert(calls == 2);
258     assert(root.value != (10+20)*5);
259     assert(root.value == 110);
260     assert(root.TextInput.value == "110");
261 
262     // Try incrementing
263     io.nextFrame;
264     io.mousePosition = start(root.spinner._lastRectangle);
265     io.press;
266     root.draw();
267 
268     io.nextFrame;
269     io.release;
270     root.draw();
271 
272     assert(calls == 3);
273     assert(root.value == 111);
274     assert(root.TextInput.value == "111");
275 
276     // Try decrementing
277     io.nextFrame;
278     io.mousePosition = end(root.spinner._lastRectangle) - Vector2(1, 1);
279     io.press;
280     root.draw();
281 
282     io.nextFrame;
283     io.release;
284     root.draw();
285 
286     assert(calls == 4);
287     assert(root.value == 110);
288     assert(root.TextInput.value == "110");
289 
290 }
291 
292 unittest {
293 
294     import std.math;
295 
296     auto io = new HeadlessBackend;
297     auto root = floatInput();
298 
299     root.io = io;
300 
301     io.inputCharacter("10e8");
302     root.focus();
303     root.draw();
304 
305     io.nextFrame;
306     io.press(KeyboardKey.enter);
307     root.draw();
308 
309     assert(root.value.isClose(10e8));
310     assert(root.TextInput.value.among("1e+9", "1e+09"));
311 
312 }
313 
314 abstract class AbstractNumberInput : TextInput {
315 
316     mixin enableInputActions;
317 
318     public {
319 
320         /// "Spinner" controlling the decrement and increment buttons.
321         NumberInputSpinner spinner;
322 
323     }
324 
325     this(void delegate() @safe changed = null) {
326 
327         super("");
328         super.changed = changed;
329         super.value = ['0'];
330         this.spinner = numberInputSpinner(.layout!"fill", &increment, &decrement);
331         caretToEnd();
332 
333     }
334 
335     override void resizeImpl(Vector2 space) {
336 
337         super.resizeImpl(space);
338         spinner.resize(tree, theme, space);
339 
340     }
341 
342     abstract void increment();
343     abstract void decrement();
344 
345 }
346 
347 /// Increment and decrement buttons that appear on the right of number inputs.
348 class NumberInputSpinner : Node, FluidHoverable {
349 
350     mixin enableInputActions;
351 
352     /// Additional features available for number input styling
353     static class Extra : typeof(super).Extra {
354 
355         /// Image to use for the increment/decrement buttons.
356         Image buttons;
357 
358         this(Image buttons) {
359 
360             this.buttons = buttons;
361 
362         }
363 
364     }
365 
366     public {
367 
368         void delegate() @safe incremented;
369         void delegate() @safe decremented;
370 
371     }
372 
373     private {
374 
375         Rectangle _lastRectangle;
376 
377     }
378 
379     this(void delegate() @safe incremented, void delegate() @safe decremented) {
380 
381         this.incremented = incremented;
382         this.decremented = decremented;
383 
384     }
385 
386     override ref inout(bool) isDisabled() inout {
387 
388         return super.isDisabled();
389 
390     }
391 
392     override bool isHovered() const {
393 
394         return super.isHovered();
395 
396     }
397 
398     override void resizeImpl(Vector2) {
399 
400         minSize = Vector2();
401 
402     }
403 
404     protected override bool hoveredImpl(Rectangle rect, Vector2 mousePosition) {
405 
406         import fluid.utils : contains;
407 
408         return buttonsRectangle(style, rect).contains(mousePosition);
409 
410     }
411 
412     override void drawImpl(Rectangle outer, Rectangle inner) {
413 
414         auto style = pickStyle();
415 
416         style.drawBackground(io, outer);
417 
418         // If there's a texture for buttons, display it
419         if (auto texture = getTexture(style)) {
420 
421             _lastRectangle = buttonsRectangle(style, inner);
422 
423             texture.draw(_lastRectangle);
424 
425         }
426 
427     }
428 
429     /// Get rectangle for the buttons
430     Rectangle buttonsRectangle(const Style style, Rectangle inner) {
431 
432         if (auto texture = getTexture(style)) {
433 
434             const scale = inner.height / texture.height;
435             const size = Vector2(texture.width, texture.height) * scale;
436             const position = end(inner) - size;
437 
438             return Rectangle(position.tupleof, size.tupleof);
439 
440         }
441 
442         return Rectangle.init;
443 
444     }
445 
446     @(FluidInputAction.press)
447     void _pressed() {
448 
449         // Above center (increment)
450         if (io.mousePosition.y < center(_lastRectangle).y) {
451 
452             if (incremented) incremented();
453 
454         }
455 
456         // Below center (decrement)
457         else {
458 
459             if (decremented) decremented();
460 
461         }
462 
463     }
464 
465     void mouseImpl() {
466 
467     }
468 
469     /// Get texture used by the spinner.
470     protected TextureGC* getTexture(const Style style) @trusted {
471 
472         auto extra = cast(Extra) style.extra;
473 
474         if (!extra) return null;
475 
476         // Check entries for this backend
477         return extra.getTexture(io, extra.buttons);
478 
479     }
480 
481 }
482 
483 struct ExpressionResult(T) {
484 
485     T value = 0;
486     bool success;
487 
488     alias value this;
489 
490     bool opCast(T : bool)() {
491 
492         return success;
493 
494     }
495 
496     ExpressionResult op(dchar operator, ExpressionResult rhs) {
497 
498         // Both sides must be successful
499         if (success && rhs.success)
500         switch (operator) {
501 
502             case '+':
503                 return ExpressionResult(value + rhs.value, true);
504             case '-':
505                 return ExpressionResult(value - rhs.value, true);
506             case '*':
507                 return ExpressionResult(value * rhs.value, true);
508             case '/':
509                 return ExpressionResult(value / rhs.value, true);
510             default: break;
511 
512         }
513 
514         // Failure
515         return ExpressionResult.init;
516 
517     }
518 
519     string toString() const {
520 
521         import std.conv;
522 
523         if (success)
524             return value.to!string;
525         else
526             return "failure";
527 
528     }
529 
530 }
531 
532 ExpressionResult!T evaluateExpression(T, Range)(Range input) {
533 
534     import std.utf : byDchar;
535 
536     alias Result = ExpressionResult!T;
537 
538     // Skip whitespace
539     auto expression = input.byDchar.filter!(a => !a.isWhite);
540 
541     return evaluateExpressionImpl!T(expression);
542 
543 }
544 
545 unittest {
546 
547     assert(evaluateExpression!int("0") == 0);
548     assert(evaluateExpression!int("10") == 10);
549     assert(evaluateExpression!int("123") == 123);
550     assert(evaluateExpression!int("-0") == -0);
551     assert(evaluateExpression!int("-10") == -10);
552     assert(evaluateExpression!int("-123") == -123);
553 
554     assert(evaluateExpression!int("2+2") == 4);
555     assert(evaluateExpression!int("2+2-3") == 1);
556     assert(evaluateExpression!int("1+1*10+3") == 14);
557     assert(evaluateExpression!int("1+2*10+3") == 24);
558     assert(evaluateExpression!int("10+-10") == 0);
559     assert(evaluateExpression!int("4*8") == 32);
560     assert(evaluateExpression!int("20/5") == 4);
561 
562     assert(evaluateExpression!int("3/4") == 0);
563     assert(evaluateExpression!float("3/4") == 0.75);
564 
565     assert(evaluateExpression!int("(4+5)*2") == 18);
566     assert(evaluateExpression!int("(4+5)+2*2") == 9+4);
567     assert(evaluateExpression!int("(4+4*5)*10+7") == (4+4*5)*10+7);
568     assert(evaluateExpression!int("102+(4+4*5)*10+7") == 102+(4+4*5)*10+7);
569 
570 }
571 
572 unittest {
573 
574     import std.math;
575     import std.conv;
576 
577     assert(evaluateExpression!float("2.0+2.0").isClose(4.0));
578     assert(evaluateExpression!float("2.4*4.2").to!string == "10.08");
579     assert(evaluateExpression!float("3/4").isClose(0.75));
580     assert(evaluateExpression!float("2 * 0.75").isClose(1.5));
581     assert(evaluateExpression!float("-2 * 0.75 * 100").isClose(-150));
582 
583     assert(evaluateExpression!float("2e8").isClose(2e8));
584     assert(evaluateExpression!float("-2e8").isClose(-2e8));
585     assert(evaluateExpression!float("2e+8").isClose(2e+8));
586     assert(evaluateExpression!float("2e-8").to!string == "2e-08");
587     assert(evaluateExpression!float("-2e+8").isClose(-2e+8));
588 
589 }
590 
591 private {
592 
593     ExpressionResult!T evaluateExpressionImpl(T, Range)(ref Range input, int minPrecedence = 1) {
594 
595         // Reference: https://eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing
596 
597         // Evaluate the left-hand side
598         auto lhs = evaluateAtom!T(input);
599 
600         // Load binary operator chain
601         while (!input.empty) {
602 
603             const operator = input.front;
604             const precedence = .precedence(operator);
605             const nextMinPrecedence = precedence + 1;
606 
607             // Precedence too low
608             if (precedence < minPrecedence) break;
609 
610             input.popFront;
611 
612             auto rhs = evaluateExpressionImpl!T(input, nextMinPrecedence);
613 
614             lhs = lhs.op(operator, rhs);
615 
616         }
617 
618         return lhs;
619 
620     }
621 
622     int precedence(dchar operator) {
623 
624         if (operator.among('+', '-'))
625             return 1;
626 
627         else if (operator.among('*', '/'))
628             return 2;
629 
630         // Error
631         else return 0;
632 
633     }
634 
635     ExpressionResult!T evaluateAtom(T, Range)(ref Range expression) {
636 
637         bool negative;
638         ExpressionResult!T result;
639 
640         // Fail if there's nothing ahead
641         if (expression.empty) return result.init;
642 
643         // Negate the value
644         if (expression.front == '-') {
645 
646             negative = true;
647             expression.popFront;
648 
649         }
650 
651         // Found paren
652         if (expression.front == '(') {
653 
654             expression.popFront;
655 
656             // Load an expression
657             result = evaluateExpressionImpl!T(expression);
658 
659             // Expect it to end
660             if (expression.front != ')')
661                 return result.init;
662 
663             expression.popFront;
664 
665         }
666 
667         // Load the number
668         else {
669 
670             import std.conv;
671 
672             bool exponent;
673 
674             // Parsing floats is hard! We'll just locate the end of the number and use std.conv.to.
675             auto length = expression.countUntil!((a) {
676 
677                 // Allow 'e+' and 'e-'
678                 if (a == 'e') {
679                     exponent = true;
680                     return false;
681                 }
682                 if (exponent && a.among('+', '-')) {
683                     exponent = false;
684                     return false;
685                 }
686 
687                 // Take in digits and dots
688                 return !a.isDigit && a != '.';
689 
690             });
691 
692             // Parse the number
693             try result.value = expression.take(length).to!T;
694             catch (ConvException)
695                 return result.init;
696 
697             // Skip ahead
698             expression.popFrontN(length);
699 
700         }
701 
702         if (negative) {
703             result.value = -result.value;
704         }
705 
706         result.success = true;
707 
708         return result;
709 
710     }
711 
712 }