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 touchText() { 163 164 // Instead of calling the callback, simply mark the input as dirty 165 isDirty = true; 166 167 } 168 169 /// Submit the value. 170 @(FluidInputAction.submit) 171 override void submit() { 172 173 // Evaluate the expression 174 evaluate(); 175 176 // Submit 177 super.submit(); 178 179 } 180 181 } 182 183 /// 184 unittest { 185 186 // intInput lets the user specify any integer value 187 intInput(); 188 189 // Float input allows floating point values 190 floatInput(); 191 192 // Specify a callback to update other components as the value of this input changes 193 IntInput myInput; 194 195 myInput = intInput(delegate { 196 197 int result = myInput.value; 198 199 }); 200 201 } 202 203 unittest { 204 205 int calls; 206 207 auto io = new HeadlessBackend; 208 auto root = intInput(delegate { 209 210 calls++; 211 212 }); 213 214 root.io = io; 215 216 // First frame: initial state 217 root.focus(); 218 root.draw(); 219 220 assert(root.value == 0); 221 assert(root.TextInput.value == "0"); 222 223 // Second frame, type in "10" 224 io.nextFrame(); 225 io.inputCharacter("10"); 226 root.draw(); 227 228 // Value should remain unchanged 229 assert(calls == 0); 230 assert(root.value == 0); 231 assert(root.TextInput.value.among("010", "10")); 232 233 // Hit enter to update 234 io.nextFrame; 235 io.press(KeyboardKey.enter); 236 root.draw(); 237 238 assert(calls == 1); 239 assert(root.value == 10); 240 assert(root.TextInput.value == "10"); 241 242 // Test math equations 243 io.nextFrame; 244 io.inputCharacter("+20*5"); 245 io.release(KeyboardKey.enter); 246 root.focus(); 247 root.draw(); 248 249 assert(calls == 1); 250 assert(root.value == 10); 251 assert(root.TextInput.value == "10+20*5"); 252 253 // Submit the expression 254 io.nextFrame; 255 io.press(KeyboardKey.enter); 256 root.draw(); 257 258 assert(calls == 2); 259 assert(root.value != (10+20)*5); 260 assert(root.value == 110); 261 assert(root.TextInput.value == "110"); 262 263 // Try incrementing 264 io.nextFrame; 265 io.mousePosition = start(root.spinner._lastRectangle); 266 io.press; 267 root.draw(); 268 269 io.nextFrame; 270 io.release; 271 root.draw(); 272 273 assert(calls == 3); 274 assert(root.value == 111); 275 assert(root.TextInput.value == "111"); 276 277 // Try decrementing 278 io.nextFrame; 279 io.mousePosition = end(root.spinner._lastRectangle) - Vector2(1, 1); 280 io.press; 281 root.draw(); 282 283 io.nextFrame; 284 io.release; 285 root.draw(); 286 287 assert(calls == 4); 288 assert(root.value == 110); 289 assert(root.TextInput.value == "110"); 290 291 } 292 293 unittest { 294 295 import std.math; 296 297 auto io = new HeadlessBackend; 298 auto root = floatInput(); 299 300 root.io = io; 301 302 io.inputCharacter("10e8"); 303 root.focus(); 304 root.draw(); 305 306 io.nextFrame; 307 io.press(KeyboardKey.enter); 308 root.draw(); 309 310 assert(root.value.isClose(10e8)); 311 assert(root.TextInput.value.among("1e+9", "1e+09")); 312 313 } 314 315 abstract class AbstractNumberInput : TextInput { 316 317 mixin enableInputActions; 318 319 public { 320 321 /// "Spinner" controlling the decrement and increment buttons. 322 NumberInputSpinner spinner; 323 324 } 325 326 this(void delegate() @safe changed = null) { 327 328 super(""); 329 super.changed = changed; 330 super.value = ['0']; 331 this.spinner = numberInputSpinner(.layout!"fill", &increment, &decrement); 332 caretToEnd(); 333 334 } 335 336 override void resizeImpl(Vector2 space) { 337 338 super.resizeImpl(space); 339 spinner.resize(tree, theme, space); 340 341 } 342 343 abstract void increment(); 344 abstract void decrement(); 345 346 } 347 348 /// Increment and decrement buttons that appear on the right of number inputs. 349 class NumberInputSpinner : Node, FluidHoverable { 350 351 mixin enableInputActions; 352 353 /// Additional features available for number input styling 354 static class Extra : typeof(super).Extra { 355 356 /// Image to use for the increment/decrement buttons. 357 Image buttons; 358 359 this(Image buttons) { 360 361 this.buttons = buttons; 362 363 } 364 365 } 366 367 public { 368 369 void delegate() @safe incremented; 370 void delegate() @safe decremented; 371 372 } 373 374 private { 375 376 Rectangle _lastRectangle; 377 378 } 379 380 this(void delegate() @safe incremented, void delegate() @safe decremented) { 381 382 this.incremented = incremented; 383 this.decremented = decremented; 384 385 } 386 387 override ref inout(bool) isDisabled() inout { 388 389 return super.isDisabled(); 390 391 } 392 393 override bool isHovered() const { 394 395 return super.isHovered(); 396 397 } 398 399 override void resizeImpl(Vector2) { 400 401 minSize = Vector2(); 402 403 } 404 405 protected override bool hoveredImpl(Rectangle rect, Vector2 mousePosition) { 406 407 import fluid.utils : contains; 408 409 return buttonsRectangle(style, rect).contains(mousePosition); 410 411 } 412 413 override void drawImpl(Rectangle outer, Rectangle inner) { 414 415 auto style = pickStyle(); 416 417 style.drawBackground(io, outer); 418 419 // If there's a texture for buttons, display it 420 if (auto texture = getTexture(style)) { 421 422 _lastRectangle = buttonsRectangle(style, inner); 423 424 texture.draw(_lastRectangle); 425 426 } 427 428 } 429 430 /// Get rectangle for the buttons 431 Rectangle buttonsRectangle(const Style style, Rectangle inner) { 432 433 if (auto texture = getTexture(style)) { 434 435 const scale = inner.height / texture.height; 436 const size = Vector2(texture.width, texture.height) * scale; 437 const position = end(inner) - size; 438 439 return Rectangle(position.tupleof, size.tupleof); 440 441 } 442 443 return Rectangle.init; 444 445 } 446 447 @(FluidInputAction.press) 448 void press() { 449 450 // Above center (increment) 451 if (io.mousePosition.y < center(_lastRectangle).y) { 452 453 if (incremented) incremented(); 454 455 } 456 457 // Below center (decrement) 458 else { 459 460 if (decremented) decremented(); 461 462 } 463 464 } 465 466 void mouseImpl() { 467 468 } 469 470 /// Get texture used by the spinner. 471 protected TextureGC* getTexture(const Style style) @trusted { 472 473 auto extra = cast(Extra) style.extra; 474 475 if (!extra) return null; 476 477 // Check entries for this backend 478 return extra.getTexture(io, extra.buttons); 479 480 } 481 482 } 483 484 struct ExpressionResult(T) { 485 486 T value = 0; 487 bool success; 488 489 alias value this; 490 491 bool opCast(T : bool)() { 492 493 return success; 494 495 } 496 497 ExpressionResult op(dchar operator, ExpressionResult rhs) { 498 499 // Both sides must be successful 500 if (success && rhs.success) 501 switch (operator) { 502 503 case '+': 504 return ExpressionResult(value + rhs.value, true); 505 case '-': 506 return ExpressionResult(value - rhs.value, true); 507 case '*': 508 return ExpressionResult(value * rhs.value, true); 509 case '/': 510 return ExpressionResult(value / rhs.value, true); 511 default: break; 512 513 } 514 515 // Failure 516 return ExpressionResult.init; 517 518 } 519 520 string toString() const { 521 522 import std.conv; 523 524 if (success) 525 return value.to!string; 526 else 527 return "failure"; 528 529 } 530 531 } 532 533 ExpressionResult!T evaluateExpression(T, Range)(Range input) { 534 535 import std.utf : byDchar; 536 537 alias Result = ExpressionResult!T; 538 539 // Skip whitespace 540 auto expression = input.byDchar.filter!(a => !a.isWhite); 541 542 return evaluateExpressionImpl!T(expression); 543 544 } 545 546 unittest { 547 548 assert(evaluateExpression!int("0") == 0); 549 assert(evaluateExpression!int("10") == 10); 550 assert(evaluateExpression!int("123") == 123); 551 assert(evaluateExpression!int("-0") == -0); 552 assert(evaluateExpression!int("-10") == -10); 553 assert(evaluateExpression!int("-123") == -123); 554 555 assert(evaluateExpression!int("2+2") == 4); 556 assert(evaluateExpression!int("2+2-3") == 1); 557 assert(evaluateExpression!int("1+1*10+3") == 14); 558 assert(evaluateExpression!int("1+2*10+3") == 24); 559 assert(evaluateExpression!int("10+-10") == 0); 560 assert(evaluateExpression!int("4*8") == 32); 561 assert(evaluateExpression!int("20/5") == 4); 562 563 assert(evaluateExpression!int("3/4") == 0); 564 assert(evaluateExpression!float("3/4") == 0.75); 565 566 assert(evaluateExpression!int("(4+5)*2") == 18); 567 assert(evaluateExpression!int("(4+5)+2*2") == 9+4); 568 assert(evaluateExpression!int("(4+4*5)*10+7") == (4+4*5)*10+7); 569 assert(evaluateExpression!int("102+(4+4*5)*10+7") == 102+(4+4*5)*10+7); 570 571 } 572 573 unittest { 574 575 import std.math; 576 import std.conv; 577 578 assert(evaluateExpression!float("2.0+2.0").isClose(4.0)); 579 assert(evaluateExpression!float("2.4*4.2").to!string == "10.08"); 580 assert(evaluateExpression!float("3/4").isClose(0.75)); 581 assert(evaluateExpression!float("2 * 0.75").isClose(1.5)); 582 assert(evaluateExpression!float("-2 * 0.75 * 100").isClose(-150)); 583 584 assert(evaluateExpression!float("2e8").isClose(2e8)); 585 assert(evaluateExpression!float("-2e8").isClose(-2e8)); 586 assert(evaluateExpression!float("2e+8").isClose(2e+8)); 587 assert(evaluateExpression!float("2e-8").to!string == "2e-08"); 588 assert(evaluateExpression!float("-2e+8").isClose(-2e+8)); 589 590 } 591 592 private { 593 594 ExpressionResult!T evaluateExpressionImpl(T, Range)(ref Range input, int minPrecedence = 1) { 595 596 // Reference: https://eli.thegreenplace.net/2012/08/02/parsing-expressions-by-precedence-climbing 597 598 // Evaluate the left-hand side 599 auto lhs = evaluateAtom!T(input); 600 601 // Load binary operator chain 602 while (!input.empty) { 603 604 const operator = input.front; 605 const precedence = .precedence(operator); 606 const nextMinPrecedence = precedence + 1; 607 608 // Precedence too low 609 if (precedence < minPrecedence) break; 610 611 input.popFront; 612 613 auto rhs = evaluateExpressionImpl!T(input, nextMinPrecedence); 614 615 lhs = lhs.op(operator, rhs); 616 617 } 618 619 return lhs; 620 621 } 622 623 int precedence(dchar operator) { 624 625 if (operator.among('+', '-')) 626 return 1; 627 628 else if (operator.among('*', '/')) 629 return 2; 630 631 // Error 632 else return 0; 633 634 } 635 636 ExpressionResult!T evaluateAtom(T, Range)(ref Range expression) { 637 638 bool negative; 639 ExpressionResult!T result; 640 641 // Fail if there's nothing ahead 642 if (expression.empty) return result.init; 643 644 // Negate the value 645 if (expression.front == '-') { 646 647 negative = true; 648 expression.popFront; 649 650 } 651 652 // Found paren 653 if (expression.front == '(') { 654 655 expression.popFront; 656 657 // Load an expression 658 result = evaluateExpressionImpl!T(expression); 659 660 // Expect it to end 661 if (expression.front != ')') 662 return result.init; 663 664 expression.popFront; 665 666 } 667 668 // Load the number 669 else { 670 671 import std.conv; 672 673 bool exponent; 674 675 // Parsing floats is hard! We'll just locate the end of the number and use std.conv.to. 676 auto length = expression.countUntil!((a) { 677 678 // Allow 'e+' and 'e-' 679 if (a == 'e') { 680 exponent = true; 681 return false; 682 } 683 if (exponent && a.among('+', '-')) { 684 exponent = false; 685 return false; 686 } 687 688 // Take in digits and dots 689 return !a.isDigit && a != '.'; 690 691 }); 692 693 // Parse the number 694 try result.value = expression.take(length).to!T; 695 catch (ConvException) 696 return result.init; 697 698 // Skip ahead 699 expression.popFrontN(length); 700 701 } 702 703 if (negative) { 704 result.value = -result.value; 705 } 706 707 result.success = true; 708 709 return result; 710 711 } 712 713 }