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 text = typeof(text)(this, "", CodeHighlighterRange.init); 150 text.hasFastEdits = true; 151 } 152 153 override void resizeImpl(Vector2 available) { 154 155 assert(text.hasFastEdits); 156 157 use(canvasIO); 158 159 auto typeface = style.getTypeface; 160 typeface.setSize(io.dpi, style.fontSize); 161 162 this.text.value = super.text.value; 163 text.indentWidth = indentWidth * typeface.advance(' ').x / io.hidpiScale.x; 164 text.resize(canvasIO, available); 165 minSize = text.size; 166 167 } 168 169 override void drawImpl(Rectangle outer, Rectangle inner) { 170 171 const style = pickStyle(); 172 text.draw(canvasIO, styles, inner.start); 173 174 } 175 176 } 177 178 /// Get the full value of the text, including context provided via `prefix` and `suffix`. 179 Rope sourceValue() const { 180 181 // TODO This will allocate. Can it be avoided? 182 return prefix ~ value ~ suffix; 183 184 } 185 186 /// Get a rope representing given indent level. 187 Rope indentRope(int indentLevel = 1) const { 188 189 static tabRope = const Rope("\t"); 190 static spaceRope = const Rope(" "); 191 192 static assert(spaceRope.length == maxIndentWidth); 193 194 Rope result; 195 196 // TODO this could be more performant by using as much of a single rope as possible 197 198 // Insert a tab 199 if (useTabs) 200 foreach (i; 0 .. indentLevel) { 201 202 result ~= tabRope; 203 204 } 205 206 // Insert a space 207 else foreach (i; 0 .. indentLevel) { 208 209 result ~= spaceRope[0 .. indentWidth]; 210 211 } 212 213 return result; 214 215 } 216 217 /// `indentRope` outputs tabs if .useTabs is set. 218 @("CodeInput.indentRope outputs tabs") 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 /// `indentRope` outputs series of spaces if spaces are used for indents. This is the default behavior. 230 @("CodeInput.indentRope outputs spaces") 231 unittest { 232 233 auto root = codeInput(); 234 235 assert(root.indentRope == " "); 236 assert(root.indentRope(2) == " "); 237 assert(root.indentRope(3) == " "); 238 239 } 240 241 protected void reparse() { 242 243 const fullValue = sourceValue; 244 245 // Parse the file 246 if (highlighter) { 247 248 highlighter.parse(fullValue); 249 250 // Apply highlighting to the label 251 contentLabel.text.styleMap = highlighter.save(cast(int) prefix.length); 252 253 } 254 255 // Pass the file to the indentor 256 if (indentor && cast(Object) indentor !is cast(Object) highlighter) { 257 258 indentor.parse(fullValue); 259 260 } 261 262 } 263 264 override void resizeImpl(Vector2 vector) @trusted { 265 266 // Parse changes 267 reparse(); 268 269 // Reformat the line if requested 270 if (_automaticFormat.pending) { 271 272 const oldTarget = _automaticFormat.oldTargetIndent; 273 const newTarget = targetIndentLevelByIndex(caretIndex); 274 275 // Reformat only if the target indent changed; don't force "correct" indents on the programmer 276 if (oldTarget != newTarget) 277 reformatLine(); 278 279 _automaticFormat.pending = false; 280 281 } 282 283 // Resize the field 284 super.resizeImpl(vector); 285 286 } 287 288 override void drawImpl(Rectangle outer, Rectangle inner) { 289 290 // Reload token styles 291 contentLabel.styles[0] = pickStyle(); 292 293 if (highlighter) { 294 295 CodeToken tokenIndex; 296 while (++tokenIndex) { 297 298 token = highlighter.nextTokenName(tokenIndex); 299 300 if (token is null) break; 301 302 contentLabel.styles[tokenIndex] = pickStyle(); 303 304 } 305 306 } 307 308 super.drawImpl(outer, inner); 309 310 } 311 312 protected override bool keyboardImpl() { 313 314 auto oldValue = this.value; 315 auto format = AutomaticFormat(targetIndentLevelByIndex(caretIndex)); 316 317 auto keyboardHandled = super.keyboardImpl(); 318 319 // If the value has changed, trigger automatic reformatting 320 if (oldValue !is this.value) 321 _automaticFormat = format; 322 323 return keyboardHandled; 324 325 } 326 327 protected override bool inputActionImpl(InputActionID id, bool active) { 328 329 // Request format 330 if (active) 331 _automaticFormat = AutomaticFormat(targetIndentLevelByIndex(caretIndex)); 332 333 return false; 334 335 } 336 337 /// Returns the index of the first character in a line that is not a space, given index of any character on 338 /// the same line. 339 size_t lineHomeByIndex(size_t index) { 340 341 const indentWidth = lineByIndex(index) 342 .until!(a => !a.among(' ', '\t')) 343 .walkLength; 344 345 return lineStartByIndex(index) + indentWidth; 346 347 } 348 349 /// Get the column the given index (or caret index) is at, but count tabs as however characters they display as. 350 ptrdiff_t visualColumn(size_t i) { 351 352 // Select characters on the same before the given index 353 auto indents = lineByIndex(i)[0 .. column!char(i)]; 354 355 return foldIndents(indents); 356 357 } 358 359 /// ditto 360 ptrdiff_t visualColumn() { 361 362 return visualColumn(caretIndex); 363 364 } 365 366 /// Get indent count for offset at given index. 367 int indentLevelByIndex(size_t i) { 368 369 // Select indents on the given line 370 auto indents = lineByIndex(i).byDchar 371 .until!(a => !a.among(' ', '\t')); 372 373 return cast(int) foldIndents(indents) / indentWidth; 374 375 } 376 377 /// Count width of the given text, counting tabs using their visual size, while other characters are of width of 1 378 private auto foldIndents(T)(T input) { 379 380 return input.fold!( 381 (a, c) => c == '\t' 382 ? a + indentWidth - (a % indentWidth) 383 : a + 1)(0); 384 385 } 386 387 /// Get suitable indent size for the line at given index, according to information from `indentor`. 388 int targetIndentLevelByIndex(size_t i) { 389 390 const lineStart = lineStartByIndex(i); 391 392 // Find the previous line so it can be used as reference. 393 // For the first line, `0` is used. 394 const untilPreviousLine = value[0..lineStart].chomp; 395 const previousLineIndent = lineStart == 0 396 ? 0 397 : indentLevelByIndex(untilPreviousLine.length); 398 399 // Use the indentor if available 400 if (indentor) { 401 402 const indentEnd = lineHomeByIndex(i); 403 404 return max(0, previousLineIndent + indentor.indentDifference(indentEnd + prefix.length)); 405 406 } 407 408 // Perform basic autoindenting if indentor is not available; keep the same indent at all time 409 else return indentLevelByIndex(i); 410 411 } 412 413 @(FluidInputAction.insertTab) 414 void insertTab() { 415 416 // Indent selection 417 if (isSelecting) indent(); 418 419 // Insert a tab character 420 else if (useTabs) { 421 422 push('\t'); 423 424 } 425 426 // Align to tab 427 else { 428 429 char[maxIndentWidth] insertTab = ' '; 430 431 const newSpace = indentWidth - (column!dchar % indentWidth); 432 433 push(insertTab[0 .. newSpace]); 434 435 } 436 437 } 438 439 @(FluidInputAction.indent) 440 void indent() { 441 442 indent(1); 443 444 } 445 446 void indent(int indentCount, bool includeEmptyLines = false) { 447 448 // Write an undo/redo history entry 449 auto shot = snapshot(); 450 scope (success) pushSnapshot(shot); 451 452 // Indent every selected line 453 foreach (ref line; eachSelectedLine) { 454 455 // Skip empty lines 456 if (!includeEmptyLines && line == "") continue; 457 458 // Prepend the indent 459 line = indentRope(indentCount) ~ line; 460 461 } 462 463 } 464 465 @(FluidInputAction.outdent) 466 void outdent() { 467 468 outdent(1); 469 470 } 471 472 void outdent(int i) { 473 474 // Write an undo/redo history entry 475 auto shot = snapshot(); 476 scope (success) pushSnapshot(shot); 477 478 // Outdent every selected line 479 foreach (ref line; eachSelectedLine) { 480 481 // Do it for each indent 482 foreach (j; 0..i) { 483 484 const leadingWidth = line.take(indentWidth) 485 .until!(a => !a.among(' ', '\t')) 486 .until("\t", No.openRight) 487 .walkLength; 488 489 // Remove the tab 490 line = line[leadingWidth .. $]; 491 492 } 493 494 } 495 496 } 497 498 override void chop(bool forward = false) { 499 500 // Make it possible to backspace space-based indents 501 if (!forward && !isSelecting) { 502 503 const lineStart = lineStartByIndex(caretIndex); 504 const lineHome = lineHomeByIndex(caretIndex); 505 const isIndent = caretIndex > lineStart && caretIndex <= lineHome; 506 507 // This is an indent 508 if (isIndent) { 509 510 const line = caretLine; 511 const col = column!char; 512 const tabWidth = either(visualColumn % indentWidth, indentWidth); 513 const tabStart = max(0, col - tabWidth); 514 const allSpaces = line[tabStart .. col].all!(a => a == ' '); 515 516 // Remove spaces as if they were tabs 517 if (allSpaces) { 518 519 const oldCaretIndex = caretIndex; 520 521 // Write an undo/redo history entry 522 auto shot = snapshot(); 523 scope (success) pushSnapshot(shot); 524 525 caretLine = line[0 .. tabStart] ~ line[col .. $]; 526 caretIndex = oldCaretIndex - tabWidth; 527 528 return; 529 530 } 531 532 } 533 534 } 535 536 super.chop(forward); 537 538 } 539 540 @(FluidInputAction.breakLine) 541 override bool breakLine() { 542 543 const currentIndent = indentLevelByIndex(caretIndex); 544 545 // Break the line 546 if (super.breakLine()) { 547 548 // Copy indent from the previous line 549 // Enable continuous input to merge the indent with the line break in the history 550 _isContinuous = true; 551 push(indentRope(currentIndent)); 552 reparse(); 553 554 // Ask the autoindentor to complete the job 555 reformatLine(); 556 _isContinuous = false; 557 558 return true; 559 560 } 561 562 return false; 563 564 } 565 566 /// Reformat a line by index of any character it contains. 567 void reformatLineByIndex(size_t index) { 568 569 import std.math; 570 571 // TODO Implement reformatLine for selections 572 if (isSelecting) return; 573 574 const newIndentLevel = targetIndentLevelByIndex(index); 575 576 const line = lineByIndex(index); 577 const lineStart = lineStartByIndex(index); 578 const lineHome = lineHomeByIndex(index); 579 const lineEnd = lineEndByIndex(index); 580 const newIndent = indentRope(newIndentLevel); 581 const oldIndentLength = lineHome - lineStart; 582 583 // Ignore if indent is the same 584 if (newIndent.length == oldIndentLength) return; 585 586 const oldCaretIndex = caretIndex; 587 const newLine = newIndent ~ line[oldIndentLength .. $]; 588 589 // Write the new indent, replacing the old one 590 lineByIndex(index, newLine); 591 592 // Update caret index 593 if (oldCaretIndex >= lineStart && oldCaretIndex <= lineEnd) 594 caretIndex = clamp(oldCaretIndex + newIndent.length - oldIndentLength, 595 lineStart + newIndent.length, 596 lineStart + newLine.length); 597 598 // Parse again 599 reparse(); 600 601 } 602 603 /// Reformat the current line. 604 void reformatLine() { 605 606 reformatLineByIndex(caretIndex); 607 608 } 609 610 /// CodeInput moves `toLineStart` action handler to `toggleHome` 611 override void caretToLineStart() { 612 613 super.caretToLineStart(); 614 615 } 616 617 /// Move the caret to the "home" position of the line, see `lineHomeByIndex`. 618 void caretToLineHome() { 619 620 caretIndex = lineHomeByIndex(caretIndex); 621 updateCaretPosition(true); 622 moveOrClearSelection(); 623 horizontalAnchor = caretPosition.x; 624 625 } 626 627 /// Move the caret to the "home" position of the line — or if the caret is already at that position, move it to 628 /// line start. This function perceives the line visually, so if the text wraps, it will go to the beginning of the 629 /// visible line, instead of the hard line break or the home. 630 /// 631 /// See_Also: `caretToLineHome` and `lineHomeByIndex` 632 @(FluidInputAction.toLineStart) 633 void toggleHome() { 634 635 const home = lineHomeByIndex(caretIndex); 636 const oldIndex = caretIndex; 637 638 // Move to visual start of line 639 caretToLineStart(); 640 641 const shouldMove = caretIndex < home 642 || caretIndex == oldIndex; 643 644 // Unless the caret was already at home, or it didn't move to start, navigate home 645 if (oldIndex != home && shouldMove) { 646 647 caretToLineHome(); 648 649 } 650 651 } 652 653 @(FluidInputAction.paste) 654 override void paste() { 655 656 import std.array : Appender; 657 658 if (clipboardIO) { 659 660 char[1024] buffer; 661 Appender!(char[]) content; 662 int offset; 663 664 // Read text from the clipboard and into the buffer 665 // This is not the most optimal, but pasting is completely reworked in the next release anyway 666 while (true) { 667 if (auto text = clipboardIO.readClipboard(buffer, offset)) { 668 content ~= text; 669 } 670 else break; 671 } 672 673 paste(content[]); 674 675 } 676 else { 677 paste(io.clipboard); 678 } 679 680 } 681 682 void paste(const char[] clipboard) { 683 684 import fluid.typeface : Typeface; 685 686 // Write an undo/redo history entry 687 auto shot = snapshot(); 688 scope (success) forcePushSnapshot(shot); 689 690 const pasteStart = selectionLowIndex; 691 auto indentLevel = indentLevelByIndex(pasteStart); 692 693 // Find the smallest indent in the clipboard 694 // Skip the first line because it's likely to be without indent when copy-pasting 695 auto lines = Typeface.lineSplitter(clipboard).drop(1); 696 697 // Count indents on each line, skip blank lines 698 auto significantIndents = lines 699 .map!(a => a 700 .countUntil!(a => !a.among(' ', '\t'))) 701 .filter!(a => a != -1); 702 703 // Test blank lines only if all lines are blank 704 const commonIndent 705 = !significantIndents.empty ? significantIndents.minElement() 706 : !lines.empty ? lines.front.length 707 : 0; 708 709 // Remove the common indent 710 auto outdentedClipboard = Typeface.lineSplitter!(Yes.keepTerminator)(clipboard) 711 .map!((a) { 712 const localIndent = a 713 .until!(a => !a.among(' ', '\t')) 714 .walkLength; 715 716 return a.drop(min(commonIndent, localIndent)); 717 }) 718 .map!(a => Rope(a)) 719 .array; 720 721 // Push the clipboard 722 push(Rope.merge(outdentedClipboard)); 723 724 reparse(); 725 726 const pasteEnd = caretIndex; 727 728 // Reformat each line 729 foreach (index, ref line; eachLineByIndex(pasteStart, pasteEnd)) { 730 731 // Save indent of the first line, but don't reformat 732 // `min` is used in case text is pasted inside the indent 733 if (index <= pasteStart) { 734 indentLevel = min(indentLevel, indentLevelByIndex(pasteStart)); 735 continue; 736 } 737 738 // Use the reformatter if available 739 if (indentor) { 740 reformatLineByIndex(index); 741 line = lineByIndex(index); 742 } 743 744 // If not, prepend the indent 745 else { 746 line = indentRope(indentLevel) ~ line; 747 } 748 749 } 750 751 // Make sure the input is parsed completely 752 reparse(); 753 754 } 755 @("CodeInput calls parse only once if Highlighter and Indentor are the same") 756 unittest { 757 758 import std.typecons; 759 760 static abstract class Highlighter : CodeHighlighter { 761 762 int highlightCount; 763 764 void parse(Rope) { 765 highlightCount++; 766 } 767 768 } 769 770 static abstract class Indentor : CodeIndentor { 771 772 int indentCount; 773 774 void parse(Rope) { 775 indentCount++; 776 } 777 778 } 779 780 auto highlighter = new BlackHole!Highlighter; 781 auto root = codeInput(highlighter); 782 root.reparse(); 783 784 assert(highlighter.highlightCount == 1); 785 786 auto indentor = new BlackHole!Indentor; 787 root.indentor = indentor; 788 root.reparse(); 789 790 // Parse called once for each 791 assert(highlighter.highlightCount == 2); 792 assert(indentor.indentCount == 1); 793 794 static abstract class FullHighlighter : CodeHighlighter, CodeIndentor { 795 796 int highlightCount; 797 int indentCount; 798 799 void parse(Rope) { 800 highlightCount++; 801 indentCount++; 802 } 803 804 } 805 806 auto fullHighlighter = new BlackHole!FullHighlighter; 807 root = codeInput(fullHighlighter); 808 root.reparse(); 809 810 // Parse should be called once for the whole class 811 assert(fullHighlighter.highlightCount == 1); 812 assert(fullHighlighter.indentCount == 1); 813 814 } 815 816 @("Legacy: CodeInput.paste creates a history entry (migrated)") 817 unittest { 818 819 auto io = new HeadlessBackend; 820 auto root = codeInput(.useSpaces(2)); 821 root.io = io; 822 823 io.clipboard = "World"; 824 root.push(" Hello,"); 825 root.runInputAction!(FluidInputAction.breakLine); 826 root.paste(); 827 assert(!root._isContinuous); 828 root.push("!"); 829 assert(root.value == " Hello,\n World!"); 830 831 // Undo the exclamation mark 832 root.undo(); 833 assert(root.value == " Hello,\n World"); 834 835 // Undo moves before pasting 836 root.undo(); 837 assert(root.value == " Hello,\n "); 838 assert(root.valueBeforeCaret == root.value); 839 840 // Next undo moves before line break 841 root.undo(); 842 assert(root.value == " Hello,"); 843 844 // Next undo clears all changes 845 root.undo(); 846 assert(root.value == ""); 847 848 // No change 849 root.undo(); 850 assert(root.value == ""); 851 852 // It can all be redone 853 root.redo(); 854 assert(root.value == " Hello,"); 855 assert(root.valueBeforeCaret == root.value); 856 root.redo(); 857 assert(root.value == " Hello,\n "); 858 assert(root.valueBeforeCaret == root.value); 859 root.redo(); 860 assert(root.value == " Hello,\n World"); 861 assert(root.valueBeforeCaret == root.value); 862 root.redo(); 863 assert(root.value == " Hello,\n World!"); 864 assert(root.valueBeforeCaret == root.value); 865 root.redo(); 866 assert(root.value == " Hello,\n World!"); 867 868 } 869 870 } 871 872 /// 873 unittest { 874 875 // Start a code editor 876 codeInput(); 877 878 // Start a code editor that uses tabs 879 codeInput( 880 .useTabs 881 ); 882 883 // Or, 2 spaces, if you prefer — the default is 4 spaces 884 codeInput( 885 .useSpaces(2) 886 ); 887 888 } 889 890 alias CodeToken = ubyte; 891 alias CodeSlice = TextStyleSlice; 892 893 // Note: This was originally a member of CodeHighlighter, but it broke the vtable sometimes...? I wasn't able to 894 // produce a minimal example to open a bug ticket, sorry. 895 alias CodeHighlighterRange = typeof(CodeHighlighter.save()); 896 897 /// Implements syntax highlighting for `CodeInput`. 898 /// Warning: This API is unstable and might change without warning. 899 interface CodeHighlighter { 900 901 /// Get a name for the token at given index. Returns null if there isn't a token at given index. Indices must be 902 /// sequential. Starts at 1. 903 const(char)[] nextTokenName(CodeToken index); 904 905 /// Parse the given text to use with other functions in the highlighter. 906 void parse(Rope text); 907 908 /// Find the next important range starting with the byte at given index. 909 /// 910 /// Tip: Query is likely to be called again with `byteIndex` set to the value of `range.end`. 911 /// 912 /// Returns: 913 /// The next relevant code range. Parts with no highlighting should be ignored. If there is nothing left to 914 /// highlight, should return `init`. 915 CodeSlice query(size_t byteIndex) 916 in (byteIndex != size_t.max, "Invalid byte index (-1)") 917 out (r; r.end != byteIndex, "query() must not return empty ranges"); 918 919 /// Produce a TextStyleSlice range using the result. 920 /// Params: 921 /// offset = Number of bytes to skip. Apply the offset to all resulting items. 922 /// Returns: `CodeHighlighterRange` suitable for use as a `Text` style map. 923 final save(int offset = 0) { 924 925 static struct HighlighterRange { 926 927 CodeHighlighter highlighter; 928 TextStyleSlice front; 929 int offset; 930 931 bool empty() const { 932 933 return front is front.init; 934 935 } 936 937 // Continue where the last token ended 938 void popFront() { 939 940 do front = highlighter.query(front.end + offset).offset(-offset); 941 942 // Pop again if got a null token 943 while (front.styleIndex == 0 && front !is front.init); 944 945 } 946 947 HighlighterRange save() { 948 949 return this; 950 951 } 952 953 } 954 955 return HighlighterRange(this, query(offset).offset(-offset), offset); 956 957 } 958 959 } 960 961 interface CodeIndentor { 962 963 /// Parse the given text. 964 void parse(Rope text); 965 966 /// Get indent level for the given offset, relative to the previous line. 967 /// 968 /// `CodeInput` will use the first non-white character on a line as a reference for reformatting. 969 int indentDifference(ptrdiff_t offset); 970 971 }