@adecore/editor 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layout.ts CHANGED
@@ -4,6 +4,7 @@ import { offsetOfStop, rowOfStop, stopAtOffset, stopCount, xOfStop } from './geo
4
4
  import { BIDI_COLUMNS, bidiControl, visibleBidi } from './bidi.ts';
5
5
  import { uniformGeometry } from './uniform-geometry.ts';
6
6
  import { lineChangeMapper } from './layout-changes.ts';
7
+ import { offsetMapper } from './offsets.ts';
7
8
 
8
9
  export type Affinity = 'before' | 'after';
9
10
 
@@ -193,7 +194,7 @@ const WRAP_INDENT_STEP = 2;
193
194
  const GEOMETRY_CACHE_SIZE = 400;
194
195
  const PLAIN = /^[\x20-\x7e\t]*$/;
195
196
  const GRAPHEME_LIMIT = 20000;
196
- const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
197
+ let segmenter: Intl.Segmenter | undefined;
197
198
 
198
199
  /* Where each character starts, with the length last. A very long line is cut by code unit, since splitting it by grapheme would cost more than it shows. */
199
200
  function characterOffsets(text: string): number[] {
@@ -203,7 +204,8 @@ function characterOffsets(text: string): number[] {
203
204
  offsets.push(i);
204
205
  }
205
206
  } else {
206
- for (const part of SEGMENTER.segment(text)) {
207
+ segmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
208
+ for (const part of segmenter.segment(text)) {
207
209
  offsets.push(part.index);
208
210
  }
209
211
  }
@@ -393,6 +395,74 @@ export interface LayoutConfig {
393
395
  replacements?: readonly BlockReplacement[];
394
396
  }
395
397
 
398
+ /* Lines `first` through `last`, both included. */
399
+ interface LineRange {
400
+ first: number;
401
+ last: number;
402
+ }
403
+
404
+ /* The lines an edit replaced: `first` through `oldLast` before it, `first` through `newLast` after it. */
405
+ interface LineEdit {
406
+ first: number;
407
+ oldLast: number;
408
+ newLast: number;
409
+ }
410
+
411
+ /* What the rows stand on besides the text, by the line it is at. */
412
+ interface RowStructure {
413
+ above: Map<number, BlockWidget[]>;
414
+ below: Map<number, BlockWidget[]>;
415
+ folds: Map<number, FoldState>;
416
+ replaced: Map<number, { replacement: BlockReplacement; endLine: number }>;
417
+ /* The lines whose rows are more than a plain line: with a widget, a collapsed fold, a replacement or an inlay. */
418
+ special: LineRange[];
419
+ }
420
+
421
+ /* A text row whose key follows its line, so an edit above it moves the row without a new key. */
422
+ class LineRow implements TextRow {
423
+ readonly kind = 'text';
424
+ index: number;
425
+ line: number;
426
+ lastLine: number;
427
+ tail: number | null;
428
+ head: number | null;
429
+ subRows: number;
430
+ top = 0;
431
+ height: number;
432
+
433
+ constructor(index: number, line: number, lastLine: number, tail: number | null, head: number | null, subRows: number, height: number) {
434
+ this.index = index;
435
+ this.line = line;
436
+ this.lastLine = lastLine;
437
+ this.tail = tail;
438
+ this.head = head;
439
+ this.subRows = subRows;
440
+ this.height = height;
441
+ }
442
+
443
+ get key(): string {
444
+ return `line:${this.line}`;
445
+ }
446
+ }
447
+
448
+ /* Sorted, with the ranges that overlap or touch joined. */
449
+ function mergeRanges(ranges: LineRange[]): LineRange[] {
450
+ ranges.sort((left, right) => left.first - right.first);
451
+ const merged: LineRange[] = [];
452
+ for (const range of ranges) {
453
+ const previous = merged.at(-1);
454
+ if (previous !== undefined && range.first <= previous.last + 1) {
455
+ previous.last = Math.max(previous.last, range.last);
456
+ } else {
457
+ merged.push({ ...range });
458
+ }
459
+ }
460
+ return merged;
461
+ }
462
+
463
+ /* Past this many lines to lay out again, building every row costs about as much, and it keeps the arguments of a splice few. */
464
+ const PATCH_LIMIT = 20000;
465
+
396
466
  /*
397
467
  * The rows of the document as they are drawn: one per line, taller for a wrapped line, one for a
398
468
  * widget, none for a line a fold hides. Only the rows that are on screen are ever measured; the
@@ -405,6 +475,9 @@ export class EditorLayout {
405
475
  /* The widest line measured since the text last changed, and the room past it; what a horizontal scroll bar needs. */
406
476
  width = 0;
407
477
  private lineRows: number[] = [];
478
+ /* The rows of widgets and replacements by key, which their measured heights come in by. */
479
+ private keyed = new Map<string, LayoutRow>();
480
+ private special: LineRange[] = [];
408
481
  private geometryCache = new Map<number, LineGeometry>();
409
482
  private measuredRows = new Map<number, { start: number; end: number; next: number; subRows: number }>();
410
483
  private measuredHeights = new Map<string, number>();
@@ -453,6 +526,7 @@ export class EditorLayout {
453
526
  this.replacements = config.replacements;
454
527
  this.lineHeightsVersion++;
455
528
  }
529
+ let edit: LineEdit | undefined;
456
530
  if (measureAgain || (config.textChanged && !config.changes?.length)) {
457
531
  this.lineHeightsVersion++;
458
532
  this.geometryCache.clear();
@@ -460,6 +534,7 @@ export class EditorLayout {
460
534
  this.width = 0;
461
535
  } else {
462
536
  if (config.changes?.length) {
537
+ edit = this.lineEdit(config.changes);
463
538
  this.retainGeometry(config.changes);
464
539
  this.lineHeightsVersion++;
465
540
  }
@@ -484,7 +559,32 @@ export class EditorLayout {
484
559
  }
485
560
  }
486
561
  }
487
- this.rebuild();
562
+ this.rebuild(edit);
563
+ }
564
+
565
+ /* The lines the transactions changed, in the coordinates of the text before and after them. */
566
+ private lineEdit(batches: readonly (readonly DocumentChange[])[]): LineEdit | undefined {
567
+ let from = Number.POSITIVE_INFINITY;
568
+ let to = Number.NEGATIVE_INFINITY;
569
+ for (const changes of batches) {
570
+ if (Number.isFinite(to)) {
571
+ to = offsetMapper(changes)(to);
572
+ }
573
+ let delta = 0;
574
+ for (const change of changes) {
575
+ from = Math.min(from, change.from);
576
+ to = Math.max(to, change.from + delta + change.insertedLength);
577
+ delta += change.insertedLength - (change.to - change.from);
578
+ }
579
+ }
580
+ if (!Number.isFinite(from)) {
581
+ return undefined;
582
+ }
583
+ // The line above too, since an edit at the start of a line can turn the `\r` that ends it into a `\r\n`.
584
+ const first = Math.max(0, this.document.positionAt(from).line - 1);
585
+ const newLast = this.document.positionAt(to).line;
586
+ const oldLast = newLast - (this.document.getLineCount() - this.lineRows.length);
587
+ return oldLast < first ? undefined : { first, oldLast, newLast };
488
588
  }
489
589
 
490
590
  private retainGeometry(batches: readonly (readonly DocumentChange[])[]): void {
@@ -527,7 +627,19 @@ export class EditorLayout {
527
627
  return Math.max(1, Math.ceil((length * this.metrics.charWidth) / Math.max(limit, this.metrics.charWidth)));
528
628
  }
529
629
 
530
- private rebuild(): void {
630
+ private rebuild(edit?: LineEdit): void {
631
+ const structure = this.structure();
632
+ if (edit === undefined || !this.patch(edit, structure)) {
633
+ this.keyed.clear();
634
+ const built = this.buildRows(structure, 0, this.document.getLineCount() - 1, 0);
635
+ this.rows = built.rows;
636
+ this.lineRows = built.lineRows;
637
+ this.reflow(0);
638
+ }
639
+ this.special = structure.special;
640
+ }
641
+
642
+ private structure(): RowStructure {
531
643
  this.inlaysByLine.clear();
532
644
  for (const inlay of this.inlays) {
533
645
  const line = this.document.positionAt(inlay.at).line;
@@ -560,15 +672,119 @@ export class EditorLayout {
560
672
  replaced.set(startLine, { replacement, endLine });
561
673
  }
562
674
  }
675
+ const special: LineRange[] = [];
676
+ for (const line of [...this.inlaysByLine.keys(), ...above.keys(), ...below.keys()]) {
677
+ special.push({ first: line, last: line });
678
+ }
679
+ for (const fold of folds.values()) {
680
+ special.push({ first: fold.startLine, last: Math.max(fold.startLine, fold.endLine) });
681
+ }
682
+ for (const [startLine, entry] of replaced) {
683
+ special.push({ first: startLine, last: entry.endLine });
684
+ }
685
+ return { above, below, folds, replaced, special: mergeRanges(special) };
686
+ }
687
+
688
+ /*
689
+ * Lays out again only the lines an edit changed and the rows that stand on more than their line, and moves the
690
+ * rest. False when that would cost no less than building every row.
691
+ */
692
+ private patch(edit: LineEdit, structure: RowStructure): boolean {
693
+ const count = this.document.getLineCount();
694
+ const delta = edit.newLast - edit.oldLast;
695
+ if (this.rows.length === 0 || this.lineRows.length + delta !== count) {
696
+ return false;
697
+ }
698
+ const moved = (range: LineRange): LineRange => {
699
+ if (range.last < edit.first) {
700
+ return range;
701
+ }
702
+ if (range.first > edit.oldLast) {
703
+ return { first: range.first + delta, last: range.last + delta };
704
+ }
705
+ return { first: Math.min(range.first, edit.first), last: range.last > edit.oldLast ? range.last + delta : edit.newLast };
706
+ };
707
+ const ranges = mergeRanges([{ first: edit.first, last: edit.newLast }, ...structure.special, ...this.special.map(moved)]).filter(
708
+ (range) => range.first < count
709
+ );
710
+ let lines = 0;
711
+ for (const range of ranges) {
712
+ range.last = Math.min(range.last, count - 1);
713
+ lines += range.last - range.first + 1;
714
+ }
715
+ if (lines > Math.min(PATCH_LIMIT, count / 2)) {
716
+ return false;
717
+ }
718
+ const rows = this.rows;
719
+ const built = new Set<LayoutRow>();
720
+ let first = rows.length;
721
+ let resized = delta !== 0;
722
+ // From the bottom up, so the rows and lines of the ranges above keep their indices. Rows not built again keep their old lines until the end.
723
+ for (let index = ranges.length - 1; index >= 0; index--) {
724
+ const range = ranges[index]!;
725
+ const oldFirst = range.first > edit.newLast ? range.first - delta : range.first;
726
+ const oldLast = range.last >= edit.newLast ? range.last - delta : range.last;
727
+ let start = this.lineRows[oldFirst]!;
728
+ while (start > 0 && rows[start - 1]!.kind === 'block' && rows[start - 1]!.line >= oldFirst) {
729
+ start--;
730
+ }
731
+ let end = this.lineRows[oldLast]!;
732
+ while (end + 1 < rows.length && rows[end + 1]!.kind === 'block' && rows[end + 1]!.line <= oldLast) {
733
+ end++;
734
+ }
735
+ const fresh = this.buildRows(structure, range.first, range.last, start);
736
+ const removed = rows.splice(start, end - start + 1, ...fresh.rows);
737
+ this.lineRows.splice(oldFirst, oldLast - oldFirst + 1, ...fresh.lineRows);
738
+ let height = 0;
739
+ for (const row of removed) {
740
+ height -= row.height;
741
+ if (this.keyed.get(row.key) === row) {
742
+ this.keyed.delete(row.key);
743
+ }
744
+ }
745
+ let top = start > 0 ? rows[start - 1]!.top + rows[start - 1]!.height : 0;
746
+ for (const row of fresh.rows) {
747
+ built.add(row);
748
+ height += row.height;
749
+ row.top = top;
750
+ top += row.height;
751
+ }
752
+ resized ||= height !== 0 || removed.length !== fresh.rows.length;
753
+ first = start;
754
+ }
755
+ if (resized) {
756
+ for (let index = first; index < rows.length; index++) {
757
+ const row = rows[index]!;
758
+ row.index = index;
759
+ if (!built.has(row) && row.line > edit.oldLast) {
760
+ row.line += delta;
761
+ if (row.kind === 'text') {
762
+ row.lastLine += delta;
763
+ }
764
+ }
765
+ if (row.kind === 'text') {
766
+ for (let covered = row.line; covered <= row.lastLine; covered++) {
767
+ this.lineRows[covered] = index;
768
+ }
769
+ }
770
+ }
771
+ this.reflow(first);
772
+ }
773
+ return true;
774
+ }
775
+
776
+ /* The rows of lines `from` through `to`, the first of them at `index`, and the row each of the lines is in. */
777
+ private buildRows(structure: RowStructure, from: number, to: number, index: number): { rows: LayoutRow[]; lineRows: number[] } {
778
+ const { above, below, folds, replaced } = structure;
563
779
  const count = this.document.getLineCount();
564
- this.rows = [];
565
- this.lineRows = new Array<number>(count);
780
+ const rows: LayoutRow[] = [];
781
+ const lineRows = new Array<number>(to - from + 1);
566
782
  const addBlock = (widget: BlockWidget, line: number): void => {
567
783
  const key = `block:${widget.id}`;
568
- this.rows.push({
784
+ const row: BlockRow = {
569
785
  key,
570
786
  kind: 'block',
571
- index: this.rows.length,
787
+ index: index + rows.length,
572
788
  line,
573
789
  widget,
574
790
  top: 0,
@@ -577,9 +793,11 @@ export class EditorLayout {
577
793
  : widget.fixed
578
794
  ? Math.max(1, widget.height ?? DEFAULT_BLOCK_HEIGHT)
579
795
  : (this.measuredHeights.get(key) ?? widget.height ?? DEFAULT_BLOCK_HEIGHT)
580
- });
796
+ };
797
+ rows.push(row);
798
+ this.keyed.set(key, row);
581
799
  };
582
- for (let line = 0; line < count; line++) {
800
+ for (let line = from; line <= to; line++) {
583
801
  for (const block of above.get(line) ?? []) {
584
802
  addBlock(block, line);
585
803
  }
@@ -587,11 +805,11 @@ export class EditorLayout {
587
805
  if (standIn !== undefined) {
588
806
  const last = Math.min(count - 1, standIn.endLine);
589
807
  const key = `replaced:${standIn.replacement.id}`;
590
- const index = this.rows.length;
591
- this.rows.push({
808
+ const rowIndex = index + rows.length;
809
+ const row: TextRow = {
592
810
  key,
593
811
  kind: 'text',
594
- index,
812
+ index: rowIndex,
595
813
  line,
596
814
  lastLine: last,
597
815
  tail: null,
@@ -600,9 +818,11 @@ export class EditorLayout {
600
818
  top: 0,
601
819
  height: this.measuredHeights.get(key) ?? standIn.replacement.height ?? DEFAULT_BLOCK_HEIGHT,
602
820
  replacement: standIn.replacement
603
- });
821
+ };
822
+ rows.push(row);
823
+ this.keyed.set(key, row);
604
824
  for (let covered = line; covered <= last; covered++) {
605
- this.lineRows[covered] = index;
825
+ lineRows[covered - from] = rowIndex;
606
826
  }
607
827
  for (const block of below.get(last) ?? []) {
608
828
  addBlock(block, last);
@@ -614,30 +834,19 @@ export class EditorLayout {
614
834
  const last = Math.max(line, Math.min(count - 1, fold?.endLine ?? line));
615
835
  const tail = fold?.closer !== undefined && last > line ? this.clampToLine(fold.closer, last) : null;
616
836
  const head = fold?.head !== undefined && last > line ? this.clampToLine(fold.head, line) : null;
617
- const index = this.rows.length;
837
+ const rowIndex = index + rows.length;
618
838
  const bounds = this.wrapWidth === null ? null : this.document.getLine(line);
619
839
  const subRows = this.rowCountOf(line, bounds === null ? 0 : bounds.end - bounds.start);
620
- this.rows.push({
621
- key: `line:${line}`,
622
- kind: 'text',
623
- index,
624
- line,
625
- lastLine: last,
626
- tail,
627
- head,
628
- subRows,
629
- top: 0,
630
- height: subRows * this.metrics.lineHeight
631
- });
840
+ rows.push(new LineRow(rowIndex, line, last, tail, head, subRows, subRows * this.metrics.lineHeight));
632
841
  for (let covered = line; covered <= last; covered++) {
633
- this.lineRows[covered] = index;
842
+ lineRows[covered - from] = rowIndex;
634
843
  }
635
844
  for (const block of below.get(line) ?? []) {
636
845
  addBlock(block, line);
637
846
  }
638
847
  line = last;
639
848
  }
640
- this.reflow();
849
+ return { rows, lineRows };
641
850
  }
642
851
 
643
852
  /* An offset of a fold clamped to its line, since the fold may be a moment older than an edit. */
@@ -646,31 +855,43 @@ export class EditorLayout {
646
855
  return Math.min(bounds.end, Math.max(bounds.start, offset));
647
856
  }
648
857
 
649
- private reflow(): void {
650
- let top = 0;
651
- for (const row of this.rows) {
858
+ /* The tops of the rows from `from` on. */
859
+ private reflow(from: number): void {
860
+ const previous = this.rows[from - 1];
861
+ let top = previous === undefined ? 0 : previous.top + previous.height;
862
+ for (let index = from; index < this.rows.length; index++) {
863
+ const row = this.rows[index]!;
652
864
  row.top = top;
653
865
  top += row.height;
654
866
  }
655
867
  this.height = top + BOTTOM_PADDING;
656
868
  }
657
869
 
870
+ /* The row of a widget, a replacement or a line by its key. */
871
+ rowByKey(key: string): LayoutRow | undefined {
872
+ if (key.startsWith('line:')) {
873
+ const row = this.rowForLine(Number(key.slice(5)));
874
+ return row.key === key ? row : undefined;
875
+ }
876
+ return this.keyed.get(key);
877
+ }
878
+
658
879
  /* A widget's measured height. Returns whether it moved anything. */
659
880
  setMeasuredHeight(key: string, height: number): boolean {
660
- const row = this.rows.find((candidate) => candidate.key === key);
881
+ const row = this.rowByKey(key);
661
882
  const next = Math.max(1, Math.round(height));
662
883
  if (!row || (row.kind === 'block' && row.widget.fixed) || !Number.isFinite(next) || row.height === next) {
663
884
  return false;
664
885
  }
665
886
  this.measuredHeights.set(key, next);
666
887
  row.height = next;
667
- this.reflow();
888
+ this.reflow(row.index);
668
889
  return true;
669
890
  }
670
891
 
671
892
  /* The visual lines a drawn row really has, which its estimate may have missed. Returns whether any row changed. */
672
893
  syncRows(rows: readonly LayoutRow[]): boolean {
673
- let changed = false;
894
+ let first = this.rows.length;
674
895
  for (const row of rows) {
675
896
  if (row.kind !== 'text' || row.replacement !== undefined) {
676
897
  continue;
@@ -688,14 +909,15 @@ export class EditorLayout {
688
909
  if (subRows !== row.subRows) {
689
910
  row.subRows = subRows;
690
911
  row.height = subRows * this.metrics.lineHeight;
691
- changed = true;
912
+ first = Math.min(first, row.index);
692
913
  }
693
914
  }
694
- if (changed) {
915
+ if (first < this.rows.length) {
695
916
  this.lineHeightsVersion++;
696
- this.reflow();
917
+ this.reflow(first);
918
+ return true;
697
919
  }
698
- return changed;
920
+ return false;
699
921
  }
700
922
 
701
923
  rowAt(y: number): LayoutRow {
package/src/tokens.ts CHANGED
@@ -128,7 +128,8 @@ export class TokenCache {
128
128
  const old = this.entries[lastOld];
129
129
  this.pivotLine = old ? lastNew : -1;
130
130
  this.pivotState = old?.state;
131
- this.entries.splice(firstLine, lastOld - firstLine + 1, ...new Array<undefined>(lastNew - firstLine + 1));
131
+ // Not a spread into splice: a paste of a few hundred thousand lines would overflow the call stack.
132
+ this.entries = this.entries.slice(0, firstLine).concat(new Array<undefined>(lastNew - firstLine + 1).fill(undefined), this.entries.slice(lastOld + 1));
132
133
  this.reusableFrom = lastNew + 1;
133
134
  }
134
135
 
package/src/types.ts CHANGED
@@ -63,6 +63,8 @@ export interface EditorOptions {
63
63
  readonly smartKeys?: Partial<EditorSmartKeys>;
64
64
  /* A line at each indentation level, the one of the scope around the caret stronger; on unless false. */
65
65
  readonly guides?: boolean;
66
+ /* The headers of the blocks scrolled out of sight stay pinned on top; on unless false. */
67
+ readonly stickyScroll?: boolean;
66
68
  /* Spaces drawn as dots and tabs as arrows; off unless true. */
67
69
  readonly whitespace?: boolean;
68
70
  /* The column to draw a line at, such as the `max_line_length` of a project; none by default. */
@@ -615,6 +617,8 @@ export interface Editor {
615
617
  /* Changes what it does as a person types; the keys left out stay as they are. */
616
618
  setSmartKeys(keys: Partial<EditorSmartKeys>): void;
617
619
  setGuides(guides: boolean): void;
620
+ /* Optional so an editor from before it still fits. */
621
+ setStickyScroll?(sticky: boolean): void;
618
622
  setWhitespace(whitespace: boolean): void;
619
623
  /* The column to draw a line at; null takes it away. */
620
624
  setRightMargin(column: number | null): void;
package/src/view.ts CHANGED
@@ -9,8 +9,6 @@ import {
9
9
  type FoldingRange,
10
10
  indentationColumn,
11
11
  replacementText,
12
- scanBrackets,
13
- type BracketIndex,
14
12
  type Selection as EditorSelection
15
13
  } from '@adecore/editor-core';
16
14
  import { createSearchClient, type EditorSearchClientOptions } from './search-client.ts';
@@ -114,6 +112,8 @@ export interface ViewSettings {
114
112
  /* When the arrow that folds a block shows in the gutter. */
115
113
  foldOutline: 'off' | 'hover' | 'always';
116
114
  folding?: boolean;
115
+ /* The headers of the blocks scrolled out of sight stay pinned on top; on unless false. */
116
+ stickyScroll?: boolean;
117
117
  }
118
118
 
119
119
  /* The lines past the visible ones that are colored ahead of the scroll, and how long one slice of that work may take. */
@@ -122,6 +122,8 @@ const MIN_OVERSCAN = 400;
122
122
  const COLOR_SLICE_MS = 8;
123
123
  const FIRST_PAINT_MS = 12;
124
124
  const FOLD_DELAY_MS = 300;
125
+ /* How much text one frame lexes to pair the brackets at the carets; a large document edited all over takes a few frames. */
126
+ const BRACKET_FRAME_LIMIT = 256 * 1024;
125
127
 
126
128
  /* The space between the last character of a line and what the host puts after it. */
127
129
  const LINE_ACTION_GAP = 28;
@@ -306,11 +308,11 @@ export class EditorView {
306
308
  /* The folds somebody already decided on, a person or the defaults, so a later answer of a server leaves them as they are. */
307
309
  private settled = new Set<number>();
308
310
  private foldTimer: ReturnType<typeof setTimeout> | undefined;
311
+ private bracketTimer: ReturnType<typeof setTimeout> | undefined;
309
312
  private colorTimer: ReturnType<typeof setTimeout> | undefined;
310
313
  private noticeTimer: ReturnType<typeof setTimeout> | undefined;
311
314
  private liveTimer: ReturnType<typeof setTimeout> | undefined;
312
315
  private lineLabelProvider: ((line: number) => string | null) | null = null;
313
- private bracketCache: { revision: number; language: string | undefined; index: BracketIndex } | undefined;
314
316
  private wrapWidth: number | null = null;
315
317
  /* The offset a click past the end of a wrapped visual line chose, which its caret is drawn at the end of that line for. */
316
318
  private rowEndCaret: number | null = null;
@@ -966,7 +968,7 @@ export class EditorView {
966
968
  ranges,
967
969
  this.model,
968
970
  (from, to) => this.model.slice(from, to),
969
- (offset) => this.brackets().pairs.get(offset)
971
+ (offset) => this.model.matchBracket(offset, this.settings.language)?.mate
970
972
  )
971
973
  );
972
974
  const anchors = new Set(this.foldRanges.map((range) => range.from));
@@ -1304,7 +1306,7 @@ export class EditorView {
1304
1306
  const anchor = this.layout.rowAt(top);
1305
1307
  const offset = top - anchor.top;
1306
1308
  action();
1307
- const next = this.layout.rows.find((row) => row.key === anchor.key);
1309
+ const next = this.layout.rowByKey(anchor.key);
1308
1310
  if (next) {
1309
1311
  // Unwrapping can shrink the anchor below its old offset.
1310
1312
  this.viewport.scrollTop = Math.max(0, next.top + Math.min(offset, Math.max(0, next.height - 1)));
@@ -1329,25 +1331,16 @@ export class EditorView {
1329
1331
  return candidates.find((at) => at >= 0 && /[()[\]{}]/.test(this.model.slice(at, at + 1)));
1330
1332
  }
1331
1333
 
1332
- private brackets(): BracketIndex {
1333
- if (this.bracketCache?.revision !== this.revision || this.bracketCache.language !== this.settings.language) {
1334
- this.bracketCache = {
1335
- revision: this.revision,
1336
- language: this.settings.language,
1337
- index: scanBrackets(this.model.getText(), this.settings.language)
1338
- };
1339
- }
1340
- return this.bracketCache.index;
1341
- }
1342
-
1343
1334
  /* The offset a bracket at the caret pairs with, if it is one. */
1344
1335
  matchingBracket(selection: { anchor: number; head: number }): { at: number; mate: number | undefined } | null {
1345
1336
  const at = this.bracketAt(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), selection.head);
1346
- return at === undefined ? null : { at, mate: this.brackets().pairs.get(at) };
1337
+ return at === undefined ? null : { at, mate: this.model.matchBracket(at, this.settings.language)?.mate };
1347
1338
  }
1348
1339
 
1349
- unmatchedBracket(at: number): boolean {
1350
- return this.brackets().unmatched.has(at);
1340
+ /* Draws the highlights of the brackets at the carets that the last frame ran out of lexing for. */
1341
+ private scheduleBrackets(): void {
1342
+ clearTimeout(this.bracketTimer);
1343
+ this.bracketTimer = setTimeout(() => this.requestRender(), 0);
1351
1344
  }
1352
1345
 
1353
1346
  /* A point of the screen in the coordinates of the content, undoing the scale a canvas puts on a node. */
@@ -2118,12 +2111,18 @@ export class EditorView {
2118
2111
  const firstLine = rows[0]?.line ?? 0;
2119
2112
  const lastLine = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
2120
2113
  const highlighted = this.highlightedLines(firstLine, lastLine);
2114
+ const shownFrom = this.model.offsetAt({ line: firstLine, column: 0 });
2115
+ const shownTo = lastLine + 1 < this.model.getLineCount() ? this.model.offsetAt({ line: lastLine + 1, column: 0 }) : this.model.getLength();
2121
2116
  paintGutter(this.gutterLines, this.layout, rows, {
2122
2117
  lineNumber: (line) => this.lineNumberLabel(line),
2123
2118
  changes: this.changedLines(rows),
2124
2119
  attribution: this.attributedLines(firstLine, lastLine),
2125
2120
  highlights: highlighted,
2126
- activeLines: new Set(selections.map((selection) => this.model.positionAt(selection.head).line)),
2121
+ activeLines: new Set(
2122
+ selections
2123
+ .filter((selection) => selection.head >= shownFrom && selection.head <= shownTo)
2124
+ .map((selection) => this.model.positionAt(selection.head).line)
2125
+ ),
2127
2126
  foldable,
2128
2127
  lineLabel: (line) => this.lineLabelProvider?.(line) ?? null,
2129
2128
  foldName: (line, collapsed) => this.foldName(line, collapsed),
@@ -2197,18 +2196,28 @@ export class EditorView {
2197
2196
  }
2198
2197
  }
2199
2198
  const seen = new Set<number>();
2200
- for (const selection of selections) {
2201
- const bracket = this.matchingBracket(selection);
2202
- if (!bracket) {
2199
+ for (const [index, selection] of selections.entries()) {
2200
+ // A caret off screen has no bracket to draw, except the primary one, whose mate may be on screen.
2201
+ if (index < selections.length - 1 && (selection.head < visibleFrom - 1 || selection.head > visibleTo + 1)) {
2203
2202
  continue;
2204
2203
  }
2205
- if (bracket.mate === undefined) {
2206
- if (this.unmatchedBracket(bracket.at)) {
2207
- marks.push({ className: 'se-bracket-unmatched', rects: this.paintRectangles(bracket.at, bracket.at + 1, rows) });
2208
- }
2204
+ const at = this.bracketAt(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), selection.head);
2205
+ if (at === undefined) {
2209
2206
  continue;
2210
2207
  }
2211
- for (const offset of [bracket.at, bracket.mate]) {
2208
+ const match = this.model.matchBracket(at, this.settings.language, BRACKET_FRAME_LIMIT);
2209
+ if (match === null) {
2210
+ this.scheduleBrackets();
2211
+ break;
2212
+ }
2213
+ if (match === undefined) {
2214
+ continue;
2215
+ }
2216
+ if (match.mate === undefined) {
2217
+ marks.push({ className: 'se-bracket-unmatched', rects: this.paintRectangles(at, at + 1, rows) });
2218
+ continue;
2219
+ }
2220
+ for (const offset of [at, match.mate]) {
2212
2221
  if (!seen.has(offset)) {
2213
2222
  seen.add(offset);
2214
2223
  marks.push({ className: 'se-bracket-match', rects: this.paintRectangles(offset, offset + 1, rows) });
@@ -2654,6 +2663,9 @@ export class EditorView {
2654
2663
  }
2655
2664
 
2656
2665
  private stickyAt(scrollTop: number): StickyPlacement[] {
2666
+ if (this.settings.stickyScroll === false) {
2667
+ return [];
2668
+ }
2657
2669
  const { layout } = this;
2658
2670
  const bottomOf = (line: number): number => {
2659
2671
  const row = layout.rowForLine(line);
@@ -2806,6 +2818,7 @@ export class EditorView {
2806
2818
  this.subscription.dispose();
2807
2819
  this.resizeObserver?.disconnect();
2808
2820
  this.blockObserver?.disconnect();
2821
+ clearTimeout(this.bracketTimer);
2809
2822
  clearTimeout(this.foldTimer);
2810
2823
  clearTimeout(this.colorTimer);
2811
2824
  clearTimeout(this.noticeTimer);