1 module fluid.code_input; 2 3 import std.range; 4 import std.string; 5 import std.algorithm; 6 7 import fluid.text; 8 import fluid.input; 9 import fluid.utils; 10 import fluid.style; 11 import fluid.backend; 12 import fluid.text_input; 13 14 15 @safe: 16 17 18 /// Node parameter for `CodeInput` enabling tabs as the character used for indents. 19 /// Params: 20 /// width = Indent width; number of character a single tab corresponds to. Optional, if set to 0 or left default, 21 /// keeps the old/default value. 22 auto useTabs(int width = 0) { 23 24 struct UseTabs { 25 26 int width; 27 28 void apply(CodeInput node) { 29 node.useTabs = true; 30 if (width) 31 node.indentWidth = width; 32 } 33 34 } 35 36 return UseTabs(width); 37 38 } 39 40 /// Node parameter for `CodeInput`, setting spaces as the character used for indents. 41 /// Params: 42 /// width = Indent width; number of spaces a single indent consists of. 43 auto useSpaces(int width) { 44 45 struct UseSpaces { 46 47 int width; 48 49 void apply(CodeInput node) { 50 node.useTabs = false; 51 node.indentWidth = width; 52 } 53 54 } 55 56 return UseSpaces(width); 57 58 } 59 60 61 /// A CodeInput is a special variant of `TextInput` that provides syntax highlighting and a gutter (column with line 62 /// numbers). 63 alias codeInput = simpleConstructor!CodeInput; 64 65 /// ditto 66 class CodeInput : TextInput { 67 68 mixin enableInputActions; 69 70 enum maxIndentWidth = 16; 71 72 public { 73 74 CodeHighlighter highlighter; 75 CodeIndentor indentor; 76 77 /// Additional context to pass to the highlighter. Will not be displayed, but can be used to improve syntax 78 /// highlighting and code analysis. 79 Rope prefix; 80 81 /// ditto 82 Rope suffix; 83 84 /// Character width of a single indent level. 85 int indentWidth = 4; 86 invariant(indentWidth <= maxIndentWidth); 87 88 /// If true, uses the tab character for indents. 89 bool useTabs = false; 90 91 } 92 93 public { 94 95 /// Current token type, used for styling individual token types and **only relevant in themes**. 96 const(char)[] token; 97 98 } 99 100 private { 101 102 struct AutomaticFormat { 103 104 bool pending; 105 int oldTargetIndent; 106 107 this(int oldTargetIndent) { 108 this.pending = true; 109 this.oldTargetIndent = oldTargetIndent; 110 } 111 112 } 113 114 /// If automatic reformatting is to take place, `pending` is set to true, with `oldTargetIndent` set to the 115 /// previous value of the indent. This value is compared against the current target, and the reformatter will 116 /// only activate if there was a change. 117 AutomaticFormat _automaticFormat; 118 119 } 120 121 this(CodeHighlighter highlighter = null, void delegate() @safe submitted = null) { 122 123 this.submitted = submitted; 124 this.highlighter = highlighter; 125 this.indentor = cast(CodeIndentor) highlighter; 126 super.contentLabel = new ContentLabel; 127 128 } 129 130 inout(ContentLabel) contentLabel() inout { 131 132 return cast(inout ContentLabel) super.contentLabel; 133 134 } 135 136 override bool multiline() const { 137 138 return true; 139 140 } 141 142 class ContentLabel : TextInput.ContentLabel { 143 144 /// Use our own `Text`. 145 StyledText!CodeHighlighterRange text; 146 Style[256] styles; 147 148 this() { 149 150 text = typeof(text)(this, "", CodeHighlighterRange.init); 151 text.hasFastEdits = true; 152 153 } 154 155 override void resizeImpl(Vector2 available) { 156 157 assert(text.hasFastEdits); 158 159 auto typeface = style.getTypeface; 160 161 typeface.setSize(io.dpi, style.fontSize); 162 163 this.text.value = super.text.value; 164 text.indentWidth = indentWidth * typeface.advance(' ').x / io.hidpiScale.x; 165 text.resize(available); 166 minSize = text.size; 167 168 } 169 170 override void drawImpl(Rectangle outer, Rectangle inner) { 171 172 const style = pickStyle(); 173 text.draw(styles, inner.start); 174 175 } 176 177 } 178 179 /// Get the full value of the text, including context provided via `prefix` and `suffix`. 180 Rope sourceValue() const { 181 182 // TODO This will allocate. Can it be avoided? 183 return prefix ~ value ~ suffix; 184 185 } 186 187 /// Get a rope representing given indent level. 188 Rope indentRope(int indentLevel = 1) const { 189 190 static tabRope = const Rope("\t"); 191 static spaceRope = const Rope(" "); 192 193 static assert(spaceRope.length == maxIndentWidth); 194 195 Rope result; 196 197 // TODO this could be more performant by using as much of a single rope as possible 198 199 // Insert a tab 200 if (useTabs) 201 foreach (i; 0 .. indentLevel) { 202 203 result ~= tabRope; 204 205 } 206 207 // Insert a space 208 else foreach (i; 0 .. indentLevel) { 209 210 result ~= spaceRope[0 .. indentWidth]; 211 212 } 213 214 return result; 215 216 } 217 218 /// 219 unittest { 220 221 auto root = codeInput(.useTabs); 222 223 assert(root.indentRope == "\t"); 224 assert(root.indentRope(2) == "\t\t"); 225 assert(root.indentRope(3) == "\t\t\t"); 226 227 } 228 229 unittest { 230 231 auto root = codeInput(); 232 233 assert(root.indentRope == " "); 234 assert(root.indentRope(2) == " "); 235 assert(root.indentRope(3) == " "); 236 237 } 238 239 protected void reparse() { 240 241 const fullValue = sourceValue; 242 243 // Parse the file 244 if (highlighter) { 245 246 highlighter.parse(fullValue); 247 248 // Apply highlighting to the label 249 contentLabel.text.styleMap = highlighter.save(cast(int) prefix.length); 250 251 } 252 253 // Pass the file to the indentor 254 if (indentor && cast(Object) indentor !is cast(Object) highlighter) { 255 256 indentor.parse(fullValue); 257 258 } 259 260 } 261 262 unittest { 263 264 import std.typecons; 265 266 static abstract class Highlighter : CodeHighlighter { 267 268 int highlightCount; 269 270 void parse(Rope) { 271 highlightCount++; 272 } 273 274 } 275 276 static abstract class Indentor : CodeIndentor { 277 278 int indentCount; 279 280 void parse(Rope) { 281 indentCount++; 282 } 283 284 } 285 286 auto highlighter = new BlackHole!Highlighter; 287 auto root = codeInput(highlighter); 288 root.reparse(); 289 290 assert(highlighter.highlightCount == 1); 291 292 auto indentor = new BlackHole!Indentor; 293 root.indentor = indentor; 294 root.reparse(); 295 296 // Parse called once for each 297 assert(highlighter.highlightCount == 2); 298 assert(indentor.indentCount == 1); 299 300 static abstract class FullHighlighter : CodeHighlighter, CodeIndentor { 301 302 int highlightCount; 303 int indentCount; 304 305 void parse(Rope) { 306 highlightCount++; 307 indentCount++; 308 } 309 310 } 311 312 auto fullHighlighter = new BlackHole!FullHighlighter; 313 root = codeInput(fullHighlighter); 314 root.reparse(); 315 316 // Parse should be called once for the whole class 317 assert(fullHighlighter.highlightCount == 1); 318 assert(fullHighlighter.indentCount == 1); 319 320 } 321 322 override void resizeImpl(Vector2 vector) @trusted { 323 324 // Parse changes 325 reparse(); 326 327 // Reformat the line if requested 328 if (_automaticFormat.pending) { 329 330 const oldTarget = _automaticFormat.oldTargetIndent; 331 const newTarget = targetIndentLevelByIndex(caretIndex); 332 333 // Reformat only if the target indent changed; don't force "correct" indents on the programmer 334 if (oldTarget != newTarget) 335 reformatLine(); 336 337 _automaticFormat.pending = false; 338 339 } 340 341 // Resize the field 342 super.resizeImpl(vector); 343 344 } 345 346 override void drawImpl(Rectangle outer, Rectangle inner) { 347 348 // Reload token styles 349 contentLabel.styles[0] = pickStyle(); 350 351 if (highlighter) { 352 353 CodeToken tokenIndex; 354 while (++tokenIndex) { 355 356 token = highlighter.nextTokenName(tokenIndex); 357 358 if (token is null) break; 359 360 contentLabel.styles[tokenIndex] = pickStyle(); 361 362 } 363 364 } 365 366 super.drawImpl(outer, inner); 367 368 } 369 370 protected override bool keyboardImpl() { 371 372 auto oldValue = this.value; 373 auto format = AutomaticFormat(targetIndentLevelByIndex(caretIndex)); 374 375 auto keyboardHandled = super.keyboardImpl(); 376 377 // If the value has changed, trigger automatic reformatting 378 if (oldValue !is this.value) 379 _automaticFormat = format; 380 381 return keyboardHandled; 382 383 } 384 385 protected override bool inputActionImpl(InputActionID id, bool active) { 386 387 // Request format 388 if (active) 389 _automaticFormat = AutomaticFormat(targetIndentLevelByIndex(caretIndex)); 390 391 return false; 392 393 } 394 395 /// Returns the index of the first character in a line that is not a space, given index of any character on 396 /// the same line. 397 size_t lineHomeByIndex(size_t index) { 398 399 const indentWidth = lineByIndex(index) 400 .until!(a => !a.among(' ', '\t')) 401 .walkLength; 402 403 return lineStartByIndex(index) + indentWidth; 404 405 } 406 407 unittest { 408 409 auto root = codeInput(); 410 root.value = "a\n b"; 411 root.draw(); 412 413 assert(root.lineHomeByIndex(0) == 0); 414 assert(root.lineHomeByIndex(1) == 0); 415 assert(root.lineHomeByIndex(2) == 6); 416 assert(root.lineHomeByIndex(4) == 6); 417 assert(root.lineHomeByIndex(6) == 6); 418 assert(root.lineHomeByIndex(7) == 6); 419 420 } 421 422 unittest { 423 424 auto root = codeInput(); 425 root.value = "a\n\tb"; 426 root.draw(); 427 428 assert(root.lineHomeByIndex(0) == 0); 429 assert(root.lineHomeByIndex(1) == 0); 430 assert(root.lineHomeByIndex(2) == 3); 431 assert(root.lineHomeByIndex(3) == 3); 432 assert(root.lineHomeByIndex(4) == 3); 433 434 root.value = " \t b"; 435 foreach (i; 0 .. root.value.length) { 436 437 assert(root.lineHomeByIndex(i) == 3); 438 439 } 440 441 } 442 443 /// Get the column the given index (or caret index) is at, but count tabs as however characters they display as. 444 ptrdiff_t visualColumn(size_t i) { 445 446 // Select characters on the same before the given index 447 auto indents = lineByIndex(i)[0 .. column!char(i)]; 448 449 return foldIndents(indents); 450 451 } 452 453 /// ditto 454 ptrdiff_t visualColumn() { 455 456 return visualColumn(caretIndex); 457 458 } 459 460 unittest { 461 462 auto root = codeInput(); 463 root.value = " ą bcd"; 464 465 foreach (i; 0 .. root.value.length) { 466 assert(root.visualColumn(i) == i); 467 } 468 469 root.value = "\t \t \t \t\n"; 470 assert(root.visualColumn(0) == 0); // 0 spaces, tab 471 assert(root.visualColumn(1) == 4); // 1 space, tab 472 assert(root.visualColumn(2) == 5); 473 assert(root.visualColumn(3) == 8); // 2 spaces, tab 474 assert(root.visualColumn(4) == 9); 475 assert(root.visualColumn(5) == 10); 476 assert(root.visualColumn(6) == 12); // 3 spaces, tab 477 assert(root.visualColumn(7) == 13); 478 assert(root.visualColumn(8) == 14); 479 assert(root.visualColumn(9) == 15); 480 assert(root.visualColumn(10) == 16); // Line feed 481 assert(root.visualColumn(11) == 0); 482 483 } 484 485 /// Get indent count for offset at given index. 486 int indentLevelByIndex(size_t i) { 487 488 // Select indents on the given line 489 auto indents = lineByIndex(i).byDchar 490 .until!(a => !a.among(' ', '\t')); 491 492 return cast(int) foldIndents(indents) / indentWidth; 493 494 } 495 496 /// Count width of the given text, counting tabs using their visual size, while other characters are of width of 1 497 private auto foldIndents(T)(T input) { 498 499 return input.fold!( 500 (a, c) => c == '\t' 501 ? a + indentWidth - (a % indentWidth) 502 : a + 1)(0); 503 504 } 505 506 unittest { 507 508 auto root = codeInput(); 509 root.value = "hello, \n" 510 ~ " world a\n" 511 ~ " \n" 512 ~ " foo\n" 513 ~ " world\n" 514 ~ " world\n"; 515 516 assert(root.indentLevelByIndex(0) == 0); 517 assert(root.indentLevelByIndex(11) == 0); 518 assert(root.indentLevelByIndex(24) == 1); 519 assert(root.indentLevelByIndex(29) == 1); 520 assert(root.indentLevelByIndex(37) == 1); 521 assert(root.indentLevelByIndex(48) == 2); 522 523 } 524 525 unittest { 526 527 auto root = codeInput(); 528 root.value = "hello,\t\n" 529 ~ " world\ta\n" 530 ~ "\t\n" 531 ~ "\tfoo\n" 532 ~ " \t world\n" 533 ~ "\t\tworld\n"; 534 535 assert(root.indentLevelByIndex(0) == 0); 536 assert(root.indentLevelByIndex(8) == 0); 537 assert(root.indentLevelByIndex(18) == 1); 538 assert(root.indentLevelByIndex(20) == 1); 539 assert(root.indentLevelByIndex(25) == 1); 540 assert(root.indentLevelByIndex(36) == 2); 541 542 } 543 544 /// Get suitable indent size for the line at given index, according to information from `indentor`. 545 int targetIndentLevelByIndex(size_t i) { 546 547 const lineStart = lineStartByIndex(i); 548 549 // Find the previous line so it can be used as reference. 550 // For the first line, `0` is used. 551 const untilPreviousLine = value[0..lineStart].chomp; 552 const previousLineIndent = lineStart == 0 553 ? 0 554 : indentLevelByIndex(untilPreviousLine.length); 555 556 // Use the indentor if available 557 if (indentor) { 558 559 const indentEnd = lineHomeByIndex(i); 560 561 return max(0, previousLineIndent + indentor.indentDifference(indentEnd + prefix.length)); 562 563 } 564 565 // Perform basic autoindenting if indentor is not available; keep the same indent at all time 566 else return indentLevelByIndex(i); 567 568 } 569 570 @(FluidInputAction.insertTab) 571 void insertTab() { 572 573 // Indent selection 574 if (isSelecting) indent(); 575 576 // Insert a tab character 577 else if (useTabs) { 578 579 push('\t'); 580 581 } 582 583 // Align to tab 584 else { 585 586 char[maxIndentWidth] insertTab = ' '; 587 588 const newSpace = indentWidth - (column!dchar % indentWidth); 589 590 push(insertTab[0 .. newSpace]); 591 592 } 593 594 } 595 596 unittest { 597 598 auto root = codeInput(); 599 root.insertTab(); 600 assert(root.value == " "); 601 root.push("aa"); 602 root.insertTab(); 603 assert(root.value == " aa "); 604 root.insertTab(); 605 assert(root.value == " aa "); 606 root.push("\n"); 607 root.insertTab(); 608 assert(root.value == " aa \n "); 609 root.insertTab(); 610 assert(root.value == " aa \n "); 611 root.push("||"); 612 root.insertTab(); 613 assert(root.value == " aa \n || "); 614 615 } 616 617 unittest { 618 619 auto root = codeInput(.useSpaces(2)); 620 root.insertTab(); 621 assert(root.value == " "); 622 root.push("aa"); 623 root.insertTab(); 624 assert(root.value == " aa "); 625 root.insertTab(); 626 assert(root.value == " aa "); 627 root.push("\n"); 628 root.insertTab(); 629 assert(root.value == " aa \n "); 630 root.insertTab(); 631 assert(root.value == " aa \n "); 632 root.push("||"); 633 root.insertTab(); 634 assert(root.value == " aa \n || "); 635 root.push("x"); 636 root.insertTab(); 637 assert(root.value == " aa \n || x "); 638 639 } 640 641 unittest { 642 643 auto root = codeInput(.useTabs); 644 root.insertTab(); 645 assert(root.value == "\t"); 646 root.push("aa"); 647 root.insertTab(); 648 assert(root.value == "\taa\t"); 649 root.insertTab(); 650 assert(root.value == "\taa\t\t"); 651 root.push("\n"); 652 root.insertTab(); 653 assert(root.value == "\taa\t\t\n\t"); 654 root.insertTab(); 655 assert(root.value == "\taa\t\t\n\t\t"); 656 root.push("||"); 657 root.insertTab(); 658 assert(root.value == "\taa\t\t\n\t\t||\t"); 659 660 } 661 662 unittest { 663 664 const originalValue = "Fïrst line\nSëcond line\r\n Thirð\n\n line\n Fourth line\nFifth line"; 665 666 auto root = codeInput(); 667 root.push(originalValue); 668 root.selectionStart = 19; 669 root.selectionEnd = 49; 670 671 assert(root.lineByIndex(root.selectionStart) == "Sëcond line"); 672 assert(root.lineByIndex(root.selectionEnd) == " Fourth line"); 673 674 root.insertTab(); 675 676 assert(root.value == "Fïrst line\n Sëcond line\r\n Thirð\n\n line\n Fourth line\nFifth line"); 677 assert(root.lineByIndex(root.selectionStart) == " Sëcond line"); 678 assert(root.lineByIndex(root.selectionEnd) == " Fourth line"); 679 680 root.outdent(); 681 682 assert(root.value == originalValue); 683 assert(root.lineByIndex(root.selectionStart) == "Sëcond line"); 684 assert(root.lineByIndex(root.selectionEnd) == " Fourth line"); 685 686 root.outdent(); 687 assert(root.value == "Fïrst line\nSëcond line\r\nThirð\n\nline\nFourth line\nFifth line"); 688 689 root.insertTab(); 690 assert(root.value == "Fïrst line\n Sëcond line\r\n Thirð\n\n line\n Fourth line\nFifth line"); 691 692 } 693 694 unittest { 695 696 auto root = codeInput(.useTabs); 697 698 root.push("Hello, World!"); 699 root.caretToStart(); 700 root.insertTab(); 701 assert(root.value == "\tHello, World!"); 702 assert(root.valueBeforeCaret == "\t"); 703 704 root.undo(); 705 assert(root.value == "Hello, World!"); 706 assert(root.valueBeforeCaret == ""); 707 708 root.redo(); 709 assert(root.value == "\tHello, World!"); 710 assert(root.valueBeforeCaret == "\t"); 711 712 root.caretToEnd(); 713 root.outdent(); 714 assert(root.value == "Hello, World!"); 715 assert(root.valueBeforeCaret == root.value); 716 assert(root.valueAfterCaret == ""); 717 718 root.undo(); 719 assert(root.value == "\tHello, World!"); 720 assert(root.valueBeforeCaret == root.value); 721 722 root.undo(); 723 assert(root.value == "Hello, World!"); 724 assert(root.valueBeforeCaret == ""); 725 726 root.undo(); 727 assert(root.value == ""); 728 assert(root.valueBeforeCaret == ""); 729 730 } 731 732 @(FluidInputAction.indent) 733 void indent() { 734 735 indent(1); 736 737 } 738 739 void indent(int indentCount, bool includeEmptyLines = false) { 740 741 // Write an undo/redo history entry 742 auto shot = snapshot(); 743 scope (success) pushSnapshot(shot); 744 745 // Indent every selected line 746 foreach (ref line; eachSelectedLine) { 747 748 // Skip empty lines 749 if (!includeEmptyLines && line == "") continue; 750 751 // Prepend the indent 752 line = indentRope(indentCount) ~ line; 753 754 } 755 756 } 757 758 unittest { 759 760 auto root = codeInput(); 761 root.value = "a"; 762 root.indent(); 763 assert(root.value == " a"); 764 765 root.value = "abc\ndef\nghi\njkl"; 766 root.selectSlice(4, 9); 767 root.indent(); 768 assert(root.value == "abc\n def\n ghi\njkl"); 769 770 root.indent(2); 771 assert(root.value == "abc\n def\n ghi\njkl"); 772 773 } 774 775 unittest { 776 777 auto root = codeInput(.useSpaces(3)); 778 root.value = "a"; 779 root.indent(); 780 assert(root.value == " a"); 781 782 root.value = "abc\ndef\nghi\njkl"; 783 assert(root.lineByIndex(4) == "def"); 784 root.selectSlice(4, 9); 785 root.indent(); 786 787 assert(root.value == "abc\n def\n ghi\njkl"); 788 789 root.indent(2); 790 assert(root.value == "abc\n def\n ghi\njkl"); 791 792 } 793 794 unittest { 795 796 auto root = codeInput(.useTabs); 797 root.value = "a"; 798 root.indent(); 799 assert(root.value == "\ta"); 800 801 root.value = "abc\ndef\nghi\njkl"; 802 root.selectSlice(4, 9); 803 root.indent(); 804 assert(root.value == "abc\n\tdef\n\tghi\njkl"); 805 806 root.indent(2); 807 assert(root.value == "abc\n\t\t\tdef\n\t\t\tghi\njkl"); 808 809 } 810 811 @(FluidInputAction.outdent) 812 void outdent() { 813 814 outdent(1); 815 816 } 817 818 void outdent(int i) { 819 820 // Write an undo/redo history entry 821 auto shot = snapshot(); 822 scope (success) pushSnapshot(shot); 823 824 // Outdent every selected line 825 foreach (ref line; eachSelectedLine) { 826 827 // Do it for each indent 828 foreach (j; 0..i) { 829 830 const leadingWidth = line.take(indentWidth) 831 .until!(a => !a.among(' ', '\t')) 832 .until("\t", No.openRight) 833 .walkLength; 834 835 // Remove the tab 836 line = line[leadingWidth .. $]; 837 838 } 839 840 } 841 842 } 843 844 unittest { 845 846 auto root = codeInput(); 847 root.outdent(); 848 assert(root.value == ""); 849 850 root.push(" "); 851 root.outdent(); 852 assert(root.value == ""); 853 854 root.push("\t"); 855 root.outdent(); 856 assert(root.value == ""); 857 858 root.push(" "); 859 root.outdent(); 860 assert(root.value == ""); 861 862 root.push(" "); 863 root.outdent(); 864 assert(root.value == " "); 865 866 root.push("foobarbaz "); 867 root.insertTab(); 868 root.outdent(); 869 assert(root.value == "foobarbaz "); 870 871 root.outdent(); 872 assert(root.value == "foobarbaz "); 873 874 root.push('\t'); 875 root.outdent(); 876 assert(root.value == "foobarbaz \t"); 877 878 root.push("\n abc "); 879 root.outdent(); 880 assert(root.value == "foobarbaz \t\nabc "); 881 882 root.push("\n \ta"); 883 root.outdent(); 884 assert(root.value == "foobarbaz \t\nabc \na"); 885 886 root.value = "\t \t\t\ta"; 887 root.outdent(); 888 assert(root.value == " \t\t\ta"); 889 890 root.outdent(); 891 assert(root.value == "\t\t\ta"); 892 893 root.outdent(2); 894 assert(root.value == "\ta"); 895 896 } 897 898 unittest { 899 900 auto io = new HeadlessBackend; 901 auto root = codeInput(); 902 root.io = io; 903 root.focus(); 904 905 // Tab twice 906 foreach (i; 0..2) { 907 908 assert(root.value.length == i*4); 909 910 io.nextFrame; 911 io.press(KeyboardKey.tab); 912 root.draw(); 913 914 io.nextFrame; 915 io.release(KeyboardKey.tab); 916 root.draw(); 917 918 } 919 920 io.nextFrame; 921 root.draw(); 922 923 assert(root.value == " "); 924 assert(root.valueBeforeCaret == " "); 925 926 // Outdent 927 io.nextFrame; 928 io.press(KeyboardKey.leftShift); 929 io.press(KeyboardKey.tab); 930 root.draw(); 931 932 io.nextFrame; 933 root.draw(); 934 935 assert(root.value == " "); 936 assert(root.valueBeforeCaret == " "); 937 938 } 939 940 unittest { 941 942 auto root = codeInput(.useSpaces(2)); 943 root.value = " abc"; 944 root.outdent(); 945 assert(root.value == " abc"); 946 root.outdent(); 947 assert(root.value == "abc"); 948 949 } 950 951 override void chop(bool forward = false) { 952 953 // Make it possible to backspace space-based indents 954 if (!forward && !isSelecting) { 955 956 const lineStart = lineStartByIndex(caretIndex); 957 const lineHome = lineHomeByIndex(caretIndex); 958 const isIndent = caretIndex > lineStart && caretIndex <= lineHome; 959 960 // This is an indent 961 if (isIndent) { 962 963 const line = caretLine; 964 const col = column!char; 965 const tabWidth = either(visualColumn % indentWidth, indentWidth); 966 const tabStart = max(0, col - tabWidth); 967 const allSpaces = line[tabStart .. col].all!(a => a == ' '); 968 969 // Remove spaces as if they were tabs 970 if (allSpaces) { 971 972 const oldCaretIndex = caretIndex; 973 974 // Write an undo/redo history entry 975 auto shot = snapshot(); 976 scope (success) pushSnapshot(shot); 977 978 caretLine = line[0 .. tabStart] ~ line[col .. $]; 979 caretIndex = oldCaretIndex - tabWidth; 980 981 return; 982 983 } 984 985 } 986 987 } 988 989 super.chop(forward); 990 991 } 992 993 unittest { 994 995 auto root = codeInput(); 996 root.value = q{ 997 if (condition) { 998 writeln("Hello, World!"); 999 } 1000 }; 1001 root.runInputAction!(FluidInputAction.nextWord); 1002 assert(root.caretIndex == root.value.indexOf("if")); 1003 root.chop(); 1004 assert(root.value == q{ 1005 if (condition) { 1006 writeln("Hello, World!"); 1007 } 1008 }); 1009 root.push(' '); 1010 assert(root.value == q{ 1011 if (condition) { 1012 writeln("Hello, World!"); 1013 } 1014 }); 1015 root.chop(); 1016 assert(root.value == q{ 1017 if (condition) { 1018 writeln("Hello, World!"); 1019 } 1020 }); 1021 1022 // Jump a word and remove two characters 1023 root.runInputAction!(FluidInputAction.nextWord); 1024 root.chop(); 1025 root.chop(); 1026 assert(root.value == q{ 1027 i(condition) { 1028 writeln("Hello, World!"); 1029 } 1030 }); 1031 1032 // Push two spaces, chop one 1033 root.push(" "); 1034 root.chop(); 1035 assert(root.value == q{ 1036 i (condition) { 1037 writeln("Hello, World!"); 1038 } 1039 }); 1040 1041 } 1042 1043 unittest { 1044 1045 auto root = codeInput(); 1046 // 2 spaces, tab, 7 spaces 1047 // Effectively 2.75 of an indent 1048 root.value = " \t "; 1049 root.caretToEnd(); 1050 root.chop(); 1051 1052 assert(root.value == " \t "); 1053 root.chop(); 1054 1055 // Tabs are not treated specially by chop, though 1056 // They could be, maybe, but it's such a dumb edgecase, this should be good enough for everybody 1057 // (I've checked that Kate does remove this in a single chop) 1058 assert(root.value == " \t"); 1059 root.chop(); 1060 assert(root.value == " "); 1061 root.chop(); 1062 assert(root.value == ""); 1063 1064 root.value = " \t \t \t"; 1065 root.caretToEnd(); 1066 root.chop(); 1067 assert(root.value == " \t \t "); 1068 1069 root.chop(); 1070 assert(root.value == " \t \t"); 1071 1072 root.chop(); 1073 assert(root.value == " \t "); 1074 1075 root.chop(); 1076 assert(root.value == " \t"); 1077 1078 root.value = "\t\t\t "; 1079 root.caretToEnd(); 1080 root.chop(); 1081 assert(root.value == "\t\t\t"); 1082 root.chop(); 1083 assert(root.value == "\t\t"); 1084 1085 } 1086 1087 unittest { 1088 1089 auto root = codeInput(.useSpaces(2)); 1090 root.value = " abc"; 1091 root.caretIndex = 6; 1092 root.chop(); 1093 assert(root.value == " abc"); 1094 root.chop(); 1095 assert(root.value == " abc"); 1096 root.chop(); 1097 assert(root.value == "abc"); 1098 root.chop(); 1099 assert(root.value == "abc"); 1100 1101 root.undo(); 1102 assert(root.value == " abc"); 1103 assert(root.valueAfterCaret == "abc"); 1104 1105 1106 } 1107 1108 @(FluidInputAction.breakLine) 1109 protected override bool breakLine() { 1110 1111 const currentIndent = indentLevelByIndex(caretIndex); 1112 1113 // Break the line 1114 if (super.breakLine()) { 1115 1116 // Copy indent from the previous line 1117 // Enable continuous input to merge the indent with the line break in the history 1118 _isContinuous = true; 1119 push(indentRope(currentIndent)); 1120 reparse(); 1121 1122 // Ask the autoindentor to complete the job 1123 reformatLine(); 1124 _isContinuous = false; 1125 1126 return true; 1127 1128 } 1129 1130 return false; 1131 1132 } 1133 1134 unittest { 1135 1136 auto root = codeInput(); 1137 1138 root.push("abcdef"); 1139 root.runInputAction!(FluidInputAction.breakLine); 1140 assert(root.value == "abcdef\n"); 1141 1142 root.insertTab(); 1143 root.runInputAction!(FluidInputAction.breakLine); 1144 assert(root.value == "abcdef\n \n "); 1145 1146 root.insertTab(); 1147 root.runInputAction!(FluidInputAction.breakLine); 1148 assert(root.value == "abcdef\n \n \n "); 1149 1150 root.outdent(); 1151 root.outdent(); 1152 assert(root.value == "abcdef\n \n \n"); 1153 1154 root.runInputAction!(FluidInputAction.breakLine); 1155 assert(root.value == "abcdef\n \n \n\n"); 1156 1157 root.undo(); 1158 assert(root.value == "abcdef\n \n \n"); 1159 root.undo(); 1160 assert(root.value == "abcdef\n \n \n "); 1161 root.undo(); 1162 assert(root.value == "abcdef\n \n "); 1163 root.undo(); 1164 assert(root.value == "abcdef\n \n "); 1165 root.undo(); 1166 assert(root.value == "abcdef\n "); 1167 1168 } 1169 1170 unittest { 1171 1172 auto root = codeInput(.useSpaces(2)); 1173 root.push("abcdef\n"); 1174 root.insertTab; 1175 assert(root.caretLine == " "); 1176 root.breakLine(); 1177 assert(root.caretLine == " "); 1178 root.breakLine(); 1179 root.push("a"); 1180 assert(root.caretLine == " a"); 1181 1182 assert(root.value == "abcdef\n \n \n a"); 1183 1184 } 1185 1186 unittest { 1187 1188 auto root = codeInput(); 1189 root.value = " abcdef"; 1190 root.caretIndex = 8; 1191 root.breakLine; 1192 assert(root.value == " abcd\n ef"); 1193 1194 } 1195 1196 /// Reformat a line by index of any character it contains. 1197 void reformatLineByIndex(size_t index) { 1198 1199 import std.math; 1200 1201 // TODO Implement reformatLine for selections 1202 if (isSelecting) return; 1203 1204 const newIndentLevel = targetIndentLevelByIndex(index); 1205 1206 const line = lineByIndex(index); 1207 const lineStart = lineStartByIndex(index); 1208 const lineHome = lineHomeByIndex(index); 1209 const lineEnd = lineEndByIndex(index); 1210 const newIndent = indentRope(newIndentLevel); 1211 const oldIndentLength = lineHome - lineStart; 1212 1213 // Ignore if indent is the same 1214 if (newIndent.length == oldIndentLength) return; 1215 1216 const oldCaretIndex = caretIndex; 1217 const newLine = newIndent ~ line[oldIndentLength .. $]; 1218 1219 // Write the new indent, replacing the old one 1220 lineByIndex(index, newLine); 1221 1222 // Update caret index 1223 if (oldCaretIndex >= lineStart && oldCaretIndex <= lineEnd) 1224 caretIndex = clamp(oldCaretIndex + newIndent.length - oldIndentLength, 1225 lineStart + newIndent.length, 1226 lineStart + newLine.length); 1227 1228 // Parse again 1229 reparse(); 1230 1231 } 1232 1233 /// Reformat the current line. 1234 void reformatLine() { 1235 1236 reformatLineByIndex(caretIndex); 1237 1238 } 1239 1240 unittest { 1241 1242 auto root = codeInput(); 1243 1244 // 3 tabs -> 3 indents 1245 root.push("\t\t\t"); 1246 root.breakLine(); 1247 assert(root.value == "\t\t\t\n "); 1248 1249 // mixed tabs (8 width total) -> 2 indents 1250 root.value = " \t \t"; 1251 root.caretToEnd(); 1252 root.breakLine(); 1253 assert(root.value == " \t \t\n "); 1254 1255 // 6 spaces -> 1 indent 1256 root.value = " "; 1257 root.breakLine(); 1258 assert(root.value == " \n "); 1259 1260 // Same but now with tabs 1261 root.useTabs = true; 1262 root.reformatLine; 1263 assert(root.indentRope(1) == "\t"); 1264 assert(root.value == " \n\t"); 1265 1266 // 3 tabs -> 3 indents 1267 root.value = "\t\t\t"; 1268 root.breakLine(); 1269 assert(root.value == "\t\t\t\n\t\t\t"); 1270 1271 // mixed tabs (8 width total) -> 2 indents 1272 root.value = " \t \t"; 1273 root.breakLine(); 1274 assert(root.value == " \t \t\n\t\t"); 1275 1276 // Same but now with 2 spaces 1277 root.useTabs = false; 1278 root.indentWidth = 2; 1279 root.reformatLine; 1280 assert(root.indentRope(1) == " "); 1281 assert(root.value == " \t \t\n "); 1282 1283 // 3 tabs -> 3 indents 1284 root.value = "\t\t\t\n"; 1285 root.caretToStart; 1286 root.reformatLine; 1287 assert(root.value == " \n"); 1288 1289 // mixed tabs (8 width total) -> 2 indents 1290 root.value = " \t \t"; 1291 root.breakLine(); 1292 assert(root.value == " \t \t\n "); 1293 1294 // 6 spaces -> 3 indents 1295 root.value = " "; 1296 root.breakLine(); 1297 assert(root.value == " \n "); 1298 1299 } 1300 1301 /// CodeInput moves `toLineStart` action handler to `toggleHome` 1302 override void caretToLineStart() { 1303 1304 super.caretToLineStart(); 1305 1306 } 1307 1308 /// Move the caret to the "home" position of the line, see `lineHomeByIndex`. 1309 void caretToLineHome() { 1310 1311 caretIndex = lineHomeByIndex(caretIndex); 1312 updateCaretPosition(true); 1313 moveOrClearSelection(); 1314 horizontalAnchor = caretPosition.x; 1315 1316 } 1317 1318 /// Move the caret to the "home" position of the line — or if the caret is already at that position, move it to 1319 /// line start. This function perceives the line visually, so if the text wraps, it will go to the beginning of the 1320 /// visible line, instead of the hard line break or the home. 1321 /// 1322 /// See_Also: `caretToLineHome` and `lineHomeByIndex` 1323 @(FluidInputAction.toLineStart) 1324 void toggleHome() { 1325 1326 const home = lineHomeByIndex(caretIndex); 1327 const oldIndex = caretIndex; 1328 1329 // Move to visual start of line 1330 caretToLineStart(); 1331 1332 const shouldMove = caretIndex < home 1333 || caretIndex == oldIndex; 1334 1335 // Unless the caret was already at home, or it didn't move to start, navigate home 1336 if (oldIndex != home && shouldMove) { 1337 1338 caretToLineHome(); 1339 1340 } 1341 1342 } 1343 1344 unittest { 1345 1346 auto root = codeInput(); 1347 root.value = "int main() {\n return 0;\n}"; 1348 root.caretIndex = root.value.countUntil("return"); 1349 root.draw(); 1350 assert(root.caretIndex == root.lineHomeByIndex(root.caretIndex)); 1351 1352 const home = root.caretIndex; 1353 1354 // Toggle home should move to line start, because the cursor is already at home 1355 root.toggleHome(); 1356 assert(root.caretIndex == home - 4); 1357 assert(root.caretIndex == root.lineStartByIndex(home)); 1358 1359 // Toggle again 1360 root.toggleHome(); 1361 assert(root.caretIndex == home); 1362 1363 // Move one character left 1364 root.caretIndex = root.caretIndex - 1; 1365 assert(root.caretIndex != home); 1366 root.toggleHome(); 1367 root.draw(); 1368 assert(root.caretIndex == home); 1369 1370 // Move to first line and see if toggle home works well even if there's no indent 1371 root.caretIndex = 4; 1372 root.updateCaretPosition(); 1373 root.toggleHome(); 1374 assert(root.caretIndex == 0); 1375 1376 root.toggleHome(); 1377 assert(root.caretIndex == 0); 1378 1379 // Switch to tabs 1380 const previousValue = root.value; 1381 root.useTabs = true; 1382 root.reformatLine(); 1383 assert(root.value == previousValue); 1384 1385 // Move to line below 1386 root.runInputAction!(FluidInputAction.nextLine); 1387 root.reformatLine(); 1388 assert(root.value == "int main() {\n\treturn 0;\n}"); 1389 assert(root.valueBeforeCaret == "int main() {\n\t"); 1390 1391 const secondLineHome = root.caretIndex; 1392 root.draw(); 1393 root.toggleHome(); 1394 assert(root.caretIndex == secondLineHome - 1); 1395 1396 root.toggleHome(); 1397 assert(root.caretIndex == secondLineHome); 1398 1399 } 1400 1401 unittest { 1402 1403 foreach (useTabs; [false, true]) { 1404 1405 const tabLength = useTabs ? 1 : 4; 1406 1407 auto io = new HeadlessBackend; 1408 auto root = codeInput(); 1409 root.io = io; 1410 root.useTabs = useTabs; 1411 root.value = root.indentRope ~ "long line that wraps because the viewport is too small to make it fit"; 1412 root.caretIndex = tabLength; 1413 root.draw(); 1414 1415 // Move to start 1416 root.toggleHome(); 1417 assert(root.caretIndex == 0); 1418 1419 // Move home 1420 root.toggleHome(); 1421 assert(root.caretIndex == tabLength); 1422 1423 // Move to line below 1424 root.runInputAction!(FluidInputAction.nextLine); 1425 1426 // Move to line start 1427 root.caretToLineStart(); 1428 assert(root.caretIndex > tabLength); 1429 1430 const secondLineStart = root.caretIndex; 1431 1432 // Move a few characters to the right, and move to line start again 1433 root.caretIndex = root.caretIndex + 5; 1434 root.toggleHome(); 1435 assert(root.caretIndex == secondLineStart); 1436 1437 // If the caret is already at the start, it should move home 1438 root.toggleHome(); 1439 assert(root.caretIndex == tabLength); 1440 root.toggleHome(); 1441 assert(root.caretIndex == 0); 1442 1443 } 1444 1445 } 1446 1447 @(FluidInputAction.paste) 1448 override void paste() { 1449 1450 import fluid.typeface : Typeface; 1451 1452 // Write an undo/redo history entry 1453 auto shot = snapshot(); 1454 scope (success) forcePushSnapshot(shot); 1455 1456 const pasteStart = selectionLowIndex; 1457 const clipboard = io.clipboard; 1458 auto indentLevel = indentLevelByIndex(pasteStart); 1459 1460 // Find the smallest indent in the clipboard 1461 // Skip the first line because it's likely to be without indent when copy-pasting 1462 auto lines = Typeface.lineSplitter(clipboard).drop(1); 1463 1464 // Count indents on each line, skip blank lines 1465 auto significantIndents = lines 1466 .map!(a => a 1467 .countUntil!(a => !a.among(' ', '\t'))) 1468 .filter!(a => a != -1); 1469 1470 // Test blank lines only if all lines are blank 1471 const commonIndent 1472 = !significantIndents.empty ? significantIndents.minElement() 1473 : !lines.empty ? lines.front.length 1474 : 0; 1475 1476 // Remove the common indent 1477 auto outdentedClipboard = Typeface.lineSplitter!(Yes.keepTerminator)(clipboard) 1478 .map!((a) { 1479 const localIndent = a 1480 .until!(a => !a.among(' ', '\t')) 1481 .walkLength; 1482 1483 return a.drop(min(commonIndent, localIndent)); 1484 }) 1485 .map!(a => Rope(a)) 1486 .array; 1487 1488 // Push the clipboard 1489 push(Rope.merge(outdentedClipboard)); 1490 1491 reparse(); 1492 1493 const pasteEnd = caretIndex; 1494 1495 // Reformat each line 1496 foreach (index, ref line; eachLineByIndex(pasteStart, pasteEnd)) { 1497 1498 // Save indent of the first line, but don't reformat 1499 // `min` is used in case text is pasted inside the indent 1500 if (index <= pasteStart) { 1501 indentLevel = min(indentLevel, indentLevelByIndex(pasteStart)); 1502 continue; 1503 } 1504 1505 // Use the reformatter if available 1506 if (indentor) { 1507 reformatLineByIndex(index); 1508 line = lineByIndex(index); 1509 } 1510 1511 // If not, prepend the indent 1512 else { 1513 line = indentRope(indentLevel) ~ line; 1514 } 1515 1516 } 1517 1518 // Make sure the input is parsed completely 1519 reparse(); 1520 1521 } 1522 1523 unittest { 1524 1525 auto io = new HeadlessBackend; 1526 auto root = codeInput(.useTabs); 1527 1528 io.clipboard = "text"; 1529 root.io = io; 1530 root.insertTab; 1531 root.paste(); 1532 assert(root.value == "\ttext"); 1533 1534 root.breakLine; 1535 root.paste(); 1536 assert(root.value == "\ttext\n\ttext"); 1537 1538 io.clipboard = "text\ntext"; 1539 root.value = ""; 1540 root.paste(); 1541 assert(root.value == "text\ntext"); 1542 1543 root.breakLine; 1544 root.insertTab; 1545 root.paste(); 1546 assert(root.value == "text\ntext\n\ttext\n\ttext"); 1547 1548 io.clipboard = " {\n text\n }\n"; 1549 root.value = ""; 1550 root.paste(); 1551 assert(root.value == "{\n text\n}\n"); 1552 1553 root.value = "\t"; 1554 root.caretToEnd(); 1555 root.paste(); 1556 assert(root.value == "\t{\n\t text\n\t}\n\t"); 1557 1558 root.value = "\t"; 1559 root.caretToStart(); 1560 root.paste(); 1561 assert(root.value == "{\n text\n}\n\t"); 1562 1563 } 1564 1565 unittest { 1566 1567 auto io = new HeadlessBackend; 1568 auto root = codeInput(); 1569 root.io = io; 1570 1571 foreach (i, clipboard; ["", " ", " ", "\t", "\t\t"]) { 1572 1573 io.clipboard = clipboard; 1574 root.value = ""; 1575 root.paste(); 1576 assert(root.value == clipboard, 1577 format!"Clipboard preset index %s (%s) not preserved"(i, clipboard)); 1578 1579 } 1580 1581 } 1582 1583 unittest { 1584 1585 auto io = new HeadlessBackend; 1586 auto root = codeInput(.useTabs); 1587 1588 io.clipboard = "text\ntext"; 1589 root.io = io; 1590 root.value = "let foo() {\n\tbar\t\tbaz\n}"; 1591 root.selectSlice( 1592 root.value.indexOf("bar"), 1593 root.value.indexOf("baz"), 1594 ); 1595 root.paste(); 1596 assert(root.value == "let foo() {\n\ttext\n\ttextbaz\n}"); 1597 1598 io.clipboard = "\t\ttext\n\ttext"; 1599 root.value = "let foo() {\n\tbar\t\tbaz\n}"; 1600 root.selectSlice( 1601 root.value.indexOf("bar"), 1602 root.value.indexOf("baz"), 1603 ); 1604 root.paste(); 1605 assert(root.value == "let foo() {\n\t\ttext\n\ttextbaz\n}"); 1606 1607 } 1608 1609 unittest { 1610 1611 auto io = new HeadlessBackend; 1612 auto root = codeInput(.useSpaces(2)); 1613 root.io = io; 1614 1615 io.clipboard = "World"; 1616 root.push(" Hello,"); 1617 root.runInputAction!(FluidInputAction.breakLine); 1618 root.paste(); 1619 assert(!root._isContinuous); 1620 root.push("!"); 1621 assert(root.value == " Hello,\n World!"); 1622 1623 // Undo the exclamation mark 1624 root.undo(); 1625 assert(root.value == " Hello,\n World"); 1626 1627 // Undo moves before pasting 1628 root.undo(); 1629 assert(root.value == " Hello,\n "); 1630 assert(root.valueBeforeCaret == root.value); 1631 1632 // Next undo moves before line break 1633 root.undo(); 1634 assert(root.value == " Hello,"); 1635 1636 // Next undo clears all changes 1637 root.undo(); 1638 assert(root.value == ""); 1639 1640 // No change 1641 root.undo(); 1642 assert(root.value == ""); 1643 1644 // It can all be redone 1645 root.redo(); 1646 assert(root.value == " Hello,"); 1647 assert(root.valueBeforeCaret == root.value); 1648 root.redo(); 1649 assert(root.value == " Hello,\n "); 1650 assert(root.valueBeforeCaret == root.value); 1651 root.redo(); 1652 assert(root.value == " Hello,\n World"); 1653 assert(root.valueBeforeCaret == root.value); 1654 root.redo(); 1655 assert(root.value == " Hello,\n World!"); 1656 assert(root.valueBeforeCaret == root.value); 1657 root.redo(); 1658 assert(root.value == " Hello,\n World!"); 1659 1660 } 1661 1662 unittest { 1663 1664 // Same test as above, but insert a space instead of line break 1665 1666 auto io = new HeadlessBackend; 1667 auto root = codeInput(.useSpaces(2)); 1668 root.io = io; 1669 1670 io.clipboard = "World"; 1671 root.push(" Hello,"); 1672 root.push(" "); 1673 root.paste(); 1674 root.push("!"); 1675 assert(root.value == " Hello, World!"); 1676 1677 // Undo the exclamation mark 1678 root.undo(); 1679 assert(root.value == " Hello, World"); 1680 1681 // Next undo moves before pasting, just like above 1682 root.undo(); 1683 assert(root.value == " Hello, "); 1684 assert(root.valueBeforeCaret == root.value); 1685 1686 root.undo(); 1687 assert(root.value == ""); 1688 1689 // No change 1690 root.undo(); 1691 assert(root.value == ""); 1692 1693 root.redo(); 1694 assert(root.value == " Hello, "); 1695 assert(root.valueBeforeCaret == root.value); 1696 1697 } 1698 1699 unittest { 1700 1701 auto indentor = new class CodeIndentor { 1702 1703 Rope text; 1704 1705 void parse(Rope text) { 1706 1707 this.text = text; 1708 1709 } 1710 1711 int indentDifference(ptrdiff_t offset) { 1712 1713 int lastLine; 1714 int current; 1715 int nextLine; 1716 1717 foreach (ch; text[0 .. offset+1].byDchar) { 1718 1719 if (ch == '{') 1720 nextLine++; 1721 else if (ch == '}') 1722 current--; 1723 else if (ch == '\n') { 1724 lastLine = current; 1725 current = nextLine; 1726 } 1727 1728 } 1729 1730 return current - lastLine; 1731 1732 } 1733 1734 }; 1735 auto io = new HeadlessBackend; 1736 auto root = codeInput(.useTabs); 1737 1738 // In this test, the indentor does nothing but preserve last indent 1739 io.clipboard = "text\ntext"; 1740 root.io = io; 1741 root.indentor = indentor; 1742 root.insertTab; 1743 root.paste(); 1744 assert(root.value == "\ttext\n\ttext"); 1745 1746 io.clipboard = "let foo() {\n\tbar\n}"; 1747 root.value = ""; 1748 root.paste(); 1749 assert(root.value == "let foo() {\n\tbar\n}"); 1750 1751 root.caretIndex = root.value.indexOf("bar"); 1752 root.runInputAction!(FluidInputAction.selectNextWord); 1753 assert(root.selectedValue == "bar"); 1754 1755 root.paste(); 1756 assert(root.value == "let foo() {\n\tlet foo() {\n\t\tbar\n\t}\n}"); 1757 1758 } 1759 1760 unittest { 1761 1762 auto io = new HeadlessBackend; 1763 auto root = codeInput(.useTabs); 1764 1765 io.clipboard = " foo\n "; 1766 root.io = io; 1767 root.value = "let foo() {\n\t\n}"; 1768 root.caretIndex = root.value.indexOf("\n}"); 1769 root.paste(); 1770 assert(root.value == "let foo() {\n\tfoo\n\t\n}"); 1771 1772 io.clipboard = "foo\n bar\n"; 1773 root.value = "let foo() {\n\tx\n}"; 1774 root.caretIndex = root.value.indexOf("x"); 1775 root.paste(); 1776 assert(root.value == "let foo() {\n\tfoo\n\tbar\n\tx\n}"); 1777 1778 } 1779 1780 } 1781 1782 /// 1783 unittest { 1784 1785 // Start a code editor 1786 codeInput(); 1787 1788 // Start a code editor that uses tabs 1789 codeInput( 1790 .useTabs 1791 ); 1792 1793 // Or, 2 spaces, if you prefer — the default is 4 spaces 1794 codeInput( 1795 .useSpaces(2) 1796 ); 1797 1798 } 1799 1800 alias CodeToken = ubyte; 1801 alias CodeSlice = TextStyleSlice; 1802 1803 // Note: This was originally a member of CodeHighlighter, but it broke the vtable sometimes...? I wasn't able to 1804 // produce a minimal example to open a bug ticket, sorry. 1805 alias CodeHighlighterRange = typeof(CodeHighlighter.save()); 1806 1807 /// Implements syntax highlighting for `CodeInput`. 1808 /// Warning: This API is unstable and might change without warning. 1809 interface CodeHighlighter { 1810 1811 /// Get a name for the token at given index. Returns null if there isn't a token at given index. Indices must be 1812 /// sequential. Starts at 1. 1813 const(char)[] nextTokenName(CodeToken index); 1814 1815 /// Parse the given text to use with other functions in the highlighter. 1816 void parse(Rope text); 1817 1818 /// Find the next important range starting with the byte at given index. 1819 /// 1820 /// Tip: Query is likely to be called again with `byteIndex` set to the value of `range.end`. 1821 /// 1822 /// Returns: 1823 /// The next relevant code range. Parts with no highlighting should be ignored. If there is nothing left to 1824 /// highlight, should return `init`. 1825 CodeSlice query(size_t byteIndex) 1826 in (byteIndex != size_t.max, "Invalid byte index (-1)") 1827 out (r; r.end != byteIndex, "query() must not return empty ranges"); 1828 1829 /// Produce a TextStyleSlice range using the result. 1830 /// Params: 1831 /// offset = Number of bytes to skip. Apply the offset to all resulting items. 1832 /// Returns: `CodeHighlighterRange` suitable for use as a `Text` style map. 1833 final save(int offset = 0) { 1834 1835 static struct HighlighterRange { 1836 1837 CodeHighlighter highlighter; 1838 TextStyleSlice front; 1839 int offset; 1840 1841 bool empty() const { 1842 1843 return front is front.init; 1844 1845 } 1846 1847 // Continue where the last token ended 1848 void popFront() { 1849 1850 do front = highlighter.query(front.end + offset).offset(-offset); 1851 1852 // Pop again if got a null token 1853 while (front.styleIndex == 0 && front !is front.init); 1854 1855 } 1856 1857 HighlighterRange save() { 1858 1859 return this; 1860 1861 } 1862 1863 } 1864 1865 return HighlighterRange(this, query(offset).offset(-offset), offset); 1866 1867 } 1868 1869 } 1870 1871 unittest { 1872 1873 import std.typecons : BlackHole; 1874 1875 enum tokenFunction = 1; 1876 enum tokenString = 2; 1877 1878 auto text = `print("Hello, World!")`; 1879 auto highlighter = new class BlackHole!CodeHighlighter { 1880 1881 override CodeSlice query(size_t byteIndex) { 1882 1883 if (byteIndex == 0) return CodeSlice(0, 5, tokenFunction); 1884 if (byteIndex <= 6) return CodeSlice(6, 21, tokenString); 1885 return CodeSlice.init; 1886 1887 } 1888 1889 }; 1890 1891 auto root = codeInput(highlighter); 1892 root.draw(); 1893 1894 assert(root.contentLabel.text.styleMap.equal([ 1895 TextStyleSlice(0, 5, tokenFunction), 1896 TextStyleSlice(6, 21, tokenString), 1897 ])); 1898 1899 } 1900 1901 interface CodeIndentor { 1902 1903 /// Parse the given text. 1904 void parse(Rope text); 1905 1906 /// Get indent level for the given offset, relative to the previous line. 1907 /// 1908 /// `CodeInput` will use the first non-white character on a line as a reference for reformatting. 1909 int indentDifference(ptrdiff_t offset); 1910 1911 } 1912 1913 unittest { 1914 1915 import std.typecons : BlackHole; 1916 1917 auto originalText 1918 = "void foo() {\n" 1919 ~ "fun();\n" 1920 ~ "functionCall(\n" 1921 ~ "stuff()\n" 1922 ~ ");\n" 1923 ~ " }\n"; 1924 auto formattedText 1925 = "void foo() {\n" 1926 ~ " fun();\n" 1927 ~ " functionCall(\n" 1928 ~ " stuff()\n" 1929 ~ " );\n" 1930 ~ "}\n"; 1931 1932 class Indentor : BlackHole!CodeIndentor { 1933 1934 struct Indent { 1935 ptrdiff_t offset; 1936 int indent; 1937 } 1938 1939 Indent[] indents; 1940 1941 override void parse(Rope rope) { 1942 1943 bool lineStart; 1944 1945 indents = [Indent(0, 0)]; 1946 1947 foreach (i, ch; rope.enumerate) { 1948 1949 if (ch.among('{', '(')) { 1950 indents ~= Indent(i + 1, 1); 1951 } 1952 1953 else if (ch.among('}', ')')) { 1954 indents ~= Indent(i, lineStart ? -1 : 0); 1955 } 1956 1957 else if (ch == '\n') lineStart = true; 1958 else if (ch != ' ') lineStart = false; 1959 1960 } 1961 1962 } 1963 1964 override int indentDifference(ptrdiff_t offset) { 1965 1966 return indents 1967 .filter!(a => a.offset <= offset) 1968 .tail(1) 1969 .front 1970 .indent; 1971 1972 } 1973 1974 } 1975 1976 auto indentor = new Indentor; 1977 auto highlighter = new class Indentor, CodeHighlighter { 1978 1979 const(char)[] nextTokenName(ubyte) { 1980 return null; 1981 } 1982 1983 CodeSlice query(size_t) { 1984 return CodeSlice.init; 1985 } 1986 1987 override void parse(Rope value) { 1988 super.parse(value); 1989 } 1990 1991 }; 1992 1993 auto indentorOnlyInput = codeInput(); 1994 indentorOnlyInput.indentor = indentor; 1995 auto highlighterInput = codeInput(highlighter); 1996 1997 foreach (root; [indentorOnlyInput, highlighterInput]) { 1998 1999 root.value = originalText; 2000 root.draw(); 2001 2002 // Reformat first line 2003 root.caretIndex = 0; 2004 assert(root.targetIndentLevelByIndex(0) == 0); 2005 root.reformatLine(); 2006 assert(root.value == originalText); 2007 2008 // Reformat second line 2009 root.caretIndex = 13; 2010 assert(root.indentor.indentDifference(13) == 1); 2011 assert(root.targetIndentLevelByIndex(13) == 1); 2012 root.reformatLine(); 2013 assert(root.value == formattedText[0..23] ~ originalText[19..$]); 2014 2015 // Reformat third line 2016 root.caretIndex = 24; 2017 assert(root.indentor.indentDifference(24) == 0); 2018 assert(root.targetIndentLevelByIndex(24) == 1); 2019 root.reformatLine(); 2020 assert(root.value == formattedText[0..42] ~ originalText[34..$]); 2021 2022 // Reformat fourth line 2023 root.caretIndex = 42; 2024 assert(root.indentor.indentDifference(42) == 1); 2025 assert(root.targetIndentLevelByIndex(42) == 2); 2026 root.reformatLine(); 2027 assert(root.value == formattedText[0..58] ~ originalText[42..$]); 2028 2029 // Reformat fifth line 2030 root.caretIndex = 58; 2031 assert(root.indentor.indentDifference(58) == -1); 2032 assert(root.targetIndentLevelByIndex(58) == 1); 2033 root.reformatLine(); 2034 assert(root.value == formattedText[0..65] ~ originalText[45..$]); 2035 2036 // And the last line, finally 2037 root.caretIndex = 65; 2038 assert(root.indentor.indentDifference(65) == -1); 2039 assert(root.targetIndentLevelByIndex(65) == 0); 2040 root.reformatLine(); 2041 assert(root.value == formattedText); 2042 2043 } 2044 2045 } 2046 2047 unittest { 2048 2049 import std.typecons : BlackHole; 2050 2051 class Indentor : BlackHole!CodeIndentor { 2052 2053 bool outdent; 2054 2055 override void parse(Rope rope) { 2056 2057 outdent = rope.canFind("end"); 2058 2059 } 2060 2061 override int indentDifference(ptrdiff_t offset) { 2062 2063 if (outdent) 2064 return -1; 2065 else 2066 return 1; 2067 2068 } 2069 2070 } 2071 2072 // Every new line indents. If "end" is found in the text, every new line *outdents*, effectively making the text 2073 // flat. 2074 auto io = new HeadlessBackend; 2075 auto root = codeInput(); 2076 root.io = io; 2077 root.indentor = new Indentor; 2078 root.value = "begin"; 2079 root.focus(); 2080 root.draw(); 2081 assert(root.value == "begin"); 2082 2083 // The difference defaults to 1 in this case, so the line should be indented 2084 root.reformatLine(); 2085 assert(root.value == " begin"); 2086 2087 // But, if the "end" keyword is added, it should outdent automatically 2088 io.nextFrame; 2089 io.inputCharacter = " end"; 2090 root.caretToEnd(); 2091 root.draw(); 2092 io.nextFrame; 2093 root.draw(); 2094 assert(root.value == "begin end"); 2095 2096 // Backspace also triggers updates 2097 io.nextFrame; 2098 io.press(KeyboardKey.backspace); 2099 root.draw(); 2100 io.nextFrame; 2101 io.release(KeyboardKey.backspace); 2102 root.draw(); 2103 assert(root.value == " begin en"); 2104 2105 // However, no change should be made if the keyword was in place before 2106 io.nextFrame; 2107 io.inputCharacter = " "; 2108 root.value = " begin end"; 2109 root.caretToEnd(); 2110 root.draw(); 2111 io.nextFrame; 2112 root.draw(); 2113 assert(root.value == " begin end "); 2114 2115 io.nextFrame; 2116 root.value = "Hello\n bar"; 2117 root.clearHistory(); 2118 root.caretIndex = 5; 2119 root.runInputAction!(FluidInputAction.breakLine); 2120 assert(root.value == "Hello\n \n bar"); 2121 2122 root.runInputAction!(FluidInputAction.undo); 2123 assert(root.value == "Hello\n bar"); 2124 2125 root.indent(); 2126 assert(root.value == " Hello\n bar"); 2127 2128 root.caretIndex = 9; 2129 root.runInputAction!(FluidInputAction.breakLine); 2130 assert(root.value == " Hello\n \n bar"); 2131 2132 root.runInputAction!(FluidInputAction.undo); 2133 assert(root.value == " Hello\n bar"); 2134 2135 } 2136 2137 unittest { 2138 2139 auto roots = [ 2140 codeInput(.nullTheme, .useSpaces(2)), 2141 codeInput(.nullTheme, .useTabs(2)), 2142 ]; 2143 2144 // Draw each root 2145 foreach (i, root; roots) { 2146 root.insertTab(); 2147 root.push("a"); 2148 root.draw(); 2149 } 2150 2151 assert(roots[0].value == " a"); 2152 assert(roots[1].value == "\ta"); 2153 2154 // Drawn text content has to be identical, since both have the same indent width 2155 assert(roots.all!(a => a.contentLabel.text.texture.chunks.length == 1)); 2156 assert(roots[0].contentLabel.text.texture.chunks[0].image.data 2157 == roots[1].contentLabel.text.texture.chunks[0].image.data); 2158 2159 } 2160 2161 unittest { 2162 2163 auto roots = [ 2164 codeInput(.nullTheme, .useSpaces(1)), 2165 codeInput(.nullTheme, .useSpaces(2)), 2166 codeInput(.nullTheme, .useSpaces(4)), 2167 codeInput(.nullTheme, .useSpaces(8)), 2168 codeInput(.nullTheme, .useTabs(1)), 2169 codeInput(.nullTheme, .useTabs(2)), 2170 codeInput(.nullTheme, .useTabs(4)), 2171 codeInput(.nullTheme, .useTabs(8)), 2172 ]; 2173 2174 foreach (root; roots) { 2175 root.insertTab(); 2176 root.push("a"); 2177 root.draw(); 2178 } 2179 2180 assert(roots[0].value == " a"); 2181 assert(roots[1].value == " a"); 2182 assert(roots[2].value == " a"); 2183 assert(roots[3].value == " a"); 2184 2185 foreach (root; roots[4..8]) { 2186 2187 assert(root.value == "\ta"); 2188 2189 } 2190 2191 float indentWidth(CodeInput root) { 2192 return root.contentLabel.text.indentWidth; 2193 } 2194 2195 foreach (i; [0, 4]) { 2196 2197 assert(indentWidth(roots[i + 0]) * 2 == indentWidth(roots[i + 1])); 2198 assert(indentWidth(roots[i + 1]) * 2 == indentWidth(roots[i + 2])); 2199 assert(indentWidth(roots[i + 2]) * 2 == indentWidth(roots[i + 3])); 2200 2201 } 2202 2203 foreach (i; 0..4) { 2204 2205 assert(indentWidth(roots[0 + i]) == indentWidth(roots[4 + i]), 2206 "Indent widths should be the same for both space and tab based roots"); 2207 2208 } 2209 2210 }