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