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 }