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 }