@adecore/editor 0.22.0 → 0.23.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.
Files changed (94) hide show
  1. package/dist/attribution.d.ts +9 -8
  2. package/dist/attribution.js +44 -9
  3. package/dist/bidi.d.ts +44 -0
  4. package/dist/bidi.js +26 -0
  5. package/dist/column-selection.js +2 -1
  6. package/dist/controller.d.ts +6 -1
  7. package/dist/controller.js +93 -3
  8. package/dist/editor.css +81 -1
  9. package/dist/engine.js +184 -41
  10. package/dist/fake.d.ts +43 -5
  11. package/dist/fake.js +117 -13
  12. package/dist/find-search.d.ts +26 -0
  13. package/dist/find-search.js +130 -0
  14. package/dist/find.d.ts +50 -2
  15. package/dist/find.js +575 -25
  16. package/dist/geometry-stops.d.ts +6 -0
  17. package/dist/geometry-stops.js +47 -0
  18. package/dist/grammar-preparation.d.ts +29 -0
  19. package/dist/grammar-preparation.js +147 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +2 -0
  22. package/dist/keymap-table.d.ts +10 -1
  23. package/dist/keymap-table.js +37 -1
  24. package/dist/keymap.d.ts +1 -0
  25. package/dist/keymap.js +4 -1
  26. package/dist/layout-changes.d.ts +6 -0
  27. package/dist/layout-changes.js +27 -0
  28. package/dist/layout.d.ts +45 -2
  29. package/dist/layout.js +202 -58
  30. package/dist/offsets.d.ts +1 -0
  31. package/dist/offsets.js +23 -0
  32. package/dist/outline.js +5 -4
  33. package/dist/paint.d.ts +11 -3
  34. package/dist/paint.js +180 -34
  35. package/dist/search-client.d.ts +27 -0
  36. package/dist/search-client.js +210 -0
  37. package/dist/search-protocol.d.ts +103 -0
  38. package/dist/search-protocol.js +3 -0
  39. package/dist/search-worker.d.ts +11 -0
  40. package/dist/search-worker.js +137 -0
  41. package/dist/semantic.d.ts +2 -1
  42. package/dist/semantic.js +6 -1
  43. package/dist/shiki-document.d.ts +8 -0
  44. package/dist/shiki-document.js +93 -0
  45. package/dist/shiki-worker.d.ts +65 -0
  46. package/dist/shiki-worker.js +265 -0
  47. package/dist/shiki.d.ts +1 -0
  48. package/dist/shiki.js +96 -33
  49. package/dist/testing.d.ts +1 -0
  50. package/dist/tokens.d.ts +2 -0
  51. package/dist/tokens.js +37 -7
  52. package/dist/touch.d.ts +27 -0
  53. package/dist/touch.js +100 -0
  54. package/dist/types.d.ts +84 -2
  55. package/dist/uniform-geometry.d.ts +7 -0
  56. package/dist/uniform-geometry.js +49 -0
  57. package/dist/view.d.ts +68 -17
  58. package/dist/view.js +424 -97
  59. package/dist/worker-client.d.ts +37 -0
  60. package/dist/worker-client.js +195 -0
  61. package/package.json +17 -2
  62. package/src/attribution.ts +50 -14
  63. package/src/bidi.ts +32 -0
  64. package/src/column-selection.ts +2 -1
  65. package/src/controller.ts +97 -3
  66. package/src/editor.css +81 -1
  67. package/src/engine.ts +216 -42
  68. package/src/fake.ts +152 -14
  69. package/src/find-search.ts +165 -0
  70. package/src/find.ts +622 -21
  71. package/src/geometry-stops.ts +51 -0
  72. package/src/grammar-preparation.ts +158 -0
  73. package/src/index.ts +10 -0
  74. package/src/keymap-table.ts +44 -2
  75. package/src/keymap.ts +5 -1
  76. package/src/layout-changes.ts +33 -0
  77. package/src/layout.ts +262 -63
  78. package/src/offsets.ts +24 -0
  79. package/src/outline.ts +5 -4
  80. package/src/paint.ts +202 -36
  81. package/src/search-client.ts +306 -0
  82. package/src/search-protocol.ts +100 -0
  83. package/src/search-worker.ts +171 -0
  84. package/src/semantic.ts +8 -2
  85. package/src/shiki-document.ts +143 -0
  86. package/src/shiki-worker.ts +344 -0
  87. package/src/shiki.ts +103 -33
  88. package/src/testing.ts +2 -0
  89. package/src/tokens.ts +36 -7
  90. package/src/touch.ts +134 -0
  91. package/src/types.ts +174 -8
  92. package/src/uniform-geometry.ts +57 -0
  93. package/src/view.ts +500 -102
  94. package/src/worker-client.ts +212 -0
package/src/view.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { offsetOfStop, xOfStop } from './geometry-stops.ts';
2
+ import { visibleBidi } from './bidi.ts';
1
3
  import {
2
4
  type DocumentChange,
3
5
  type DocumentModel,
@@ -11,13 +13,26 @@ import {
11
13
  type BracketIndex,
12
14
  type Selection as EditorSelection
13
15
  } from '@adecore/editor-core';
16
+ import { createSearchClient, type EditorSearchClientOptions } from './search-client.ts';
14
17
  import { FindController } from './find.ts';
15
- import { type BlockWidget, EditorLayout, type FoldState, type Inlay, type LayoutRect, type LayoutRow, RIGHT_PADDING, type TextRow } from './layout.ts';
18
+ import {
19
+ type BlockReplacement,
20
+ type BlockWidget,
21
+ EditorLayout,
22
+ type FoldState,
23
+ type Inlay,
24
+ type LayoutRect,
25
+ type LayoutRow,
26
+ RIGHT_PADDING,
27
+ type TextRow,
28
+ subRowBand,
29
+ type VerticalWindow
30
+ } from './layout.ts';
16
31
  import { createMetrics, type EditorFont, readEditorFont } from './metrics.ts';
17
32
  import { type AttributedLines, AttributionRuns, colorValue, tintValue } from './attribution.ts';
18
33
  import { renderCodeBlock } from './code-block.ts';
19
34
  import { emit, subscribe } from './listeners.ts';
20
- import { mapOffset } from './offsets.ts';
35
+ import { mapOffset, offsetMapper } from './offsets.ts';
21
36
  import { mapTrackedRange } from './tracked-range.ts';
22
37
  import { type ScrollKind, scrollPosition } from './scroll.ts';
23
38
  import { easeOut, scrollDuration } from './scroll-animation.ts';
@@ -46,11 +61,15 @@ import type {
46
61
  EditorFindQuery,
47
62
  EditorFindState,
48
63
  EditorHighlightKind,
64
+ EditorInputPopup,
65
+ EditorLineHighlight,
66
+ EditorGutterIcon,
49
67
  EditorMarkerSeverity,
50
68
  EditorMessages,
51
69
  EditorRect,
52
70
  EditorSmartKeys,
53
71
  LineToken,
72
+ LineTokenKind,
54
73
  LineTokenizer,
55
74
  ScopeColors
56
75
  } from './types.ts';
@@ -59,23 +78,24 @@ function sameRect(left: EditorRect, right: EditorRect): boolean {
59
78
  return left.left === right.left && left.top === right.top && left.right === right.right && left.bottom === right.bottom;
60
79
  }
61
80
 
62
- function mapRange<Range extends { from: number; to: number }>(range: Range, changes: readonly DocumentChange[]): Range {
63
- return { ...range, from: mapOffset(range.from, changes), to: mapOffset(range.to, changes) };
81
+ function mapRange<Range extends { from: number; to: number }>(range: Range, map: (offset: number) => number): Range {
82
+ return { ...range, from: map(range.from), to: map(range.to) };
64
83
  }
65
84
 
66
- function mapFoldHints(hints: FoldHints, changes: readonly DocumentChange[]): FoldHints {
85
+ function mapFoldHints(hints: FoldHints, map: (offset: number) => number): FoldHints {
67
86
  return {
68
- symbols: hints.symbols?.map((hint) => mapRange(hint, changes)) ?? [],
87
+ symbols: hints.symbols?.map((hint) => mapRange(hint, map)) ?? [],
69
88
  ranges:
70
89
  hints.ranges?.map((hint) => ({
71
- ...mapRange(hint, changes),
72
- ...(hint.head === undefined ? {} : { head: mapOffset(hint.head, changes) }),
73
- ...(hint.tail === undefined ? {} : { tail: mapOffset(hint.tail, changes) })
90
+ ...mapRange(hint, map),
91
+ ...(hint.head === undefined ? {} : { head: map(hint.head) }),
92
+ ...(hint.tail === undefined ? {} : { tail: map(hint.tail) })
74
93
  })) ?? []
75
94
  };
76
95
  }
77
96
 
78
97
  export interface ViewSettings {
98
+ searchWorker?: EditorSearchClientOptions['workerFactory'];
79
99
  language: string | undefined;
80
100
  tabSize: number;
81
101
  insertSpaces: boolean;
@@ -93,6 +113,7 @@ export interface ViewSettings {
93
113
  rightMargin: number | null;
94
114
  /* When the arrow that folds a block shows in the gutter. */
95
115
  foldOutline: 'off' | 'hover' | 'always';
116
+ folding?: boolean;
96
117
  }
97
118
 
98
119
  /* The lines past the visible ones that are colored ahead of the scroll, and how long one slice of that work may take. */
@@ -142,9 +163,18 @@ export interface ViewSemanticToken {
142
163
  from: number;
143
164
  to: number;
144
165
  scopes: readonly string[];
166
+ within?: LineTokenKind;
145
167
  }
146
168
 
147
169
  /* A problem the host marked, anchored to offsets that follow the text through edits. */
170
+ export interface GutterMarker {
171
+ id: string;
172
+ line: number;
173
+ label: string;
174
+ icon?: EditorGutterIcon;
175
+ pressed?: boolean;
176
+ }
177
+
148
178
  export interface ViewMarker {
149
179
  severity: EditorMarkerSeverity;
150
180
  from: number;
@@ -176,6 +206,7 @@ export class EditorView {
176
206
  private readonly preview: HTMLElement;
177
207
  private readonly signs: HTMLElement;
178
208
  private readonly notice: HTMLElement;
209
+ private readonly live: HTMLElement;
179
210
  private readonly sticky: HTMLElement;
180
211
  private readonly overview: HTMLElement;
181
212
  private readonly tickTip: HTMLElement;
@@ -187,10 +218,17 @@ export class EditorView {
187
218
  private readonly findListeners = new Set<(state: EditorFindState) => void>();
188
219
  private readonly scopeListeners = new Set<(scope: readonly EditorBlock[]) => void>();
189
220
  private readonly viewListeners = new Set<() => void>();
221
+ private readonly lineHeightListeners = new Set<() => void>();
222
+ private announcedLineHeights = -1;
190
223
  private readonly gutterActionListeners = new Set<(line: number) => void>();
191
224
  private gutterAction: { line: number; label: string } | null = null;
192
225
  private readonly gutterMarkerListeners = new Set<(id: string) => void>();
193
- private readonly gutterMarkers = new Map<string, readonly { id: string; line: number; label: string }[]>();
226
+ private readonly gutterMarkers = new Map<string, readonly GutterMarker[]>();
227
+ private lineTokenProvider: ((line: number) => readonly LineToken[] | null | undefined) | null = null;
228
+ private lineNumberProvider: ((line: number) => string | null) | null = null;
229
+ private lineNumberLabels: (string | null)[] = [];
230
+ private lineNumberRevision = -1;
231
+ private lineNumberWidth = 0;
194
232
  private semantic: ViewSemanticToken[] = [];
195
233
  private scopeColors: ScopeColors | null = null;
196
234
  private semanticVersion = 0;
@@ -204,8 +242,13 @@ export class EditorView {
204
242
  private readonly outline = new Outline();
205
243
  private stickyEntries: StickyEntry[] = [];
206
244
  private scopeKey = '';
207
- private changeMarks: { kind: EditorChangeKind; from: number; to: number }[] = [];
245
+ private readonly changeLayers = new Map<string, { kind: EditorChangeKind; from: number; to: number }[]>();
208
246
  private changeVersion = 0;
247
+ private changeCache: {
248
+ version: number;
249
+ lines: Map<number, EditorChangeKind>;
250
+ runs: { kind: EditorChangeKind; start: number; end: number }[];
251
+ } | null = null;
209
252
  private readonly attribution = new AttributionRuns();
210
253
  private readonly highlights = new AttributionRuns();
211
254
  private remoteCarets: { id: string; name: string; color: string; at: number }[] = [];
@@ -231,9 +274,14 @@ export class EditorView {
231
274
  private frameGeometry: { top: number; left: number; width: number; height: number } | null = null;
232
275
  private disposed = false;
233
276
  private inlays: Inlay[] = [];
277
+ private readonly inlayHoverListeners = new Set<(target: { id: string; part: number | null; rect: EditorRect } | null) => void>();
278
+ private inlayHover: { id: string; part: number | null } | null = null;
279
+ private inlayLink: { id: string; part: number | null } | null = null;
280
+ private inlayLinkElement: HTMLElement | null = null;
234
281
  /* The host's widgets by owner, so one owner setting its rows never takes another's away. */
235
282
  private readonly blocks = new Map<string, BlockWidget[]>();
236
283
  private lenses: BlockWidget[] = [];
284
+ private readonly replacements = new Map<string, BlockReplacement[]>();
237
285
  private occurrences: Occurrence[] = [];
238
286
  private replacePreview: { text: string; preserveCase: boolean } | null = null;
239
287
  private dropCaret: number | null = null;
@@ -260,6 +308,8 @@ export class EditorView {
260
308
  private foldTimer: ReturnType<typeof setTimeout> | undefined;
261
309
  private colorTimer: ReturnType<typeof setTimeout> | undefined;
262
310
  private noticeTimer: ReturnType<typeof setTimeout> | undefined;
311
+ private liveTimer: ReturnType<typeof setTimeout> | undefined;
312
+ private lineLabelProvider: ((line: number) => string | null) | null = null;
263
313
  private bracketCache: { revision: number; language: string | undefined; index: BracketIndex } | undefined;
264
314
  private wrapWidth: number | null = null;
265
315
  /* 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,6 +366,8 @@ export class EditorView {
316
366
  for (const name of ['autocapitalize', 'autocomplete', 'autocorrect']) {
317
367
  this.input.setAttribute(name, 'off');
318
368
  }
369
+ this.input.setAttribute('enterkeyhint', 'enter');
370
+ this.input.setAttribute('inputmode', 'text');
319
371
  this.input.setAttribute('aria-multiline', 'true');
320
372
  this.sticky.setAttribute('aria-hidden', 'true');
321
373
  this.sticky.hidden = true;
@@ -326,14 +378,31 @@ export class EditorView {
326
378
  this.notice = make('div', 'se-notice');
327
379
  this.notice.setAttribute('role', 'status');
328
380
  this.notice.hidden = true;
329
- this.root.append(this.viewport, this.overview, this.tickTip, this.input, this.notice);
381
+ this.live = make('div', 'se-live');
382
+ this.live.setAttribute('role', 'status');
383
+ this.live.setAttribute('aria-live', 'polite');
384
+ this.live.setAttribute('aria-atomic', 'true');
385
+ this.root.append(this.viewport, this.overview, this.tickTip, this.input, this.notice, this.live);
330
386
  this.wireOverview();
331
387
  this.wireAttribution();
332
388
  container.append(this.root);
333
389
 
334
390
  this.font = readEditorFont(this.root);
335
391
  this.layout = new EditorLayout(model, createMetrics(this.font, settings.tabSize, document));
336
- this.find = new FindController(model);
392
+ this.find = new FindController(model, {
393
+ createClient: () => (settings.searchWorker === undefined ? null : createSearchClient({ workerFactory: settings.searchWorker })),
394
+ changed: (reveal) => {
395
+ if (this.disposed) {
396
+ return;
397
+ }
398
+ this.announceFind();
399
+ if (reveal) {
400
+ this.revealFind();
401
+ }
402
+ this.paintPreview();
403
+ this.requestRender();
404
+ }
405
+ });
337
406
  this.tokens = new TokenCache({ getLineCount: () => model.getLineCount(), getLineText: (line) => model.getLine(line).text });
338
407
  const ResizeObserverClass = document.defaultView?.ResizeObserver;
339
408
  this.blockObserver = ResizeObserverClass
@@ -371,6 +440,11 @@ export class EditorView {
371
440
  this.render();
372
441
  }
373
442
 
443
+ /* Words the gutter draws changed, not a metric: the lines keep their layout. */
444
+ repaint(): void {
445
+ this.render();
446
+ }
447
+
374
448
  refreshFont(): void {
375
449
  this.font = readEditorFont(this.root);
376
450
  this.applySettings();
@@ -382,8 +456,37 @@ export class EditorView {
382
456
  this.render();
383
457
  }
384
458
 
459
+ grammarChanged(): void {
460
+ if (!this.disposed && this.lineTokenProvider === null && this.tokens.revalidate()) {
461
+ this.colorAhead(true);
462
+ }
463
+ }
464
+
465
+ setLineTokens(tokens: ((line: number) => readonly LineToken[] | null | undefined) | null): void {
466
+ this.lineTokenProvider = tokens;
467
+ clearTimeout(this.colorTimer);
468
+ this.semanticLines.clear();
469
+ this.layoutVersion++;
470
+ this.render();
471
+ }
472
+
473
+ setLineNumbers(labels: ((line: number) => string | null) | null): void {
474
+ this.lineNumberProvider = labels;
475
+ this.lineNumberRevision = -1;
476
+ this.stickyEntries = [];
477
+ this.render();
478
+ }
479
+
480
+ private lineNumberLabel(line: number): string | null {
481
+ return this.lineNumberProvider === null ? String(line + 1) : (this.lineNumberLabels[line] ?? null);
482
+ }
483
+
385
484
  setInlays(inlays: readonly Inlay[]): void {
386
- this.inlays = inlays.map((inlay) => ({ ...inlay, text: inlay.text.replace(/[\r\n]/g, ' ') }));
485
+ this.inlays = inlays.map((inlay) => ({
486
+ ...inlay,
487
+ text: inlay.text.replace(/[\r\n]/g, ' '),
488
+ ...(inlay.parts === undefined ? {} : { parts: inlay.parts.map((part) => part.replace(/[\r\n]/g, ' ')) })
489
+ }));
387
490
  this.configureText();
388
491
  }
389
492
 
@@ -397,6 +500,24 @@ export class EditorView {
397
500
  this.render();
398
501
  }
399
502
 
503
+ /* Lines the host draws as a row of its own, by owner; where two owners claim the same line the owner that sorts first keeps it. */
504
+ setReplacements(owner: string, replacements: readonly BlockReplacement[]): void {
505
+ if (replacements.length === 0 && !this.replacements.has(owner)) {
506
+ return;
507
+ }
508
+ if (replacements.length === 0) {
509
+ this.replacements.delete(owner);
510
+ } else {
511
+ this.replacements.set(owner, [...replacements]);
512
+ }
513
+ this.anchorScroll(() => this.layout.configure({ replacements: this.allReplacements() }));
514
+ this.render();
515
+ }
516
+
517
+ private allReplacements(): BlockReplacement[] {
518
+ return [...this.replacements.keys()].sort().flatMap((owner) => this.replacements.get(owner)!);
519
+ }
520
+
400
521
  /* The quiet rows above declarations: kept apart from the host's widgets, so setting one kind never takes the other away. */
401
522
  setLenses(lenses: readonly BlockWidget[]): void {
402
523
  this.lenses = [...lenses];
@@ -442,7 +563,7 @@ export class EditorView {
442
563
 
443
564
  /* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
444
565
  private styledTokensOf(line: number): readonly LineToken[] | null {
445
- const base = this.tokens.tokensOf(line);
566
+ const base = this.lineTokenProvider === null ? this.tokens.tokensOf(line) : (this.lineTokenProvider(line) ?? null);
446
567
  if (base === null || this.semantic.length === 0 || this.scopeColors === null) {
447
568
  return base;
448
569
  }
@@ -450,7 +571,7 @@ export class EditorView {
450
571
  if (spans === undefined) {
451
572
  return base;
452
573
  }
453
- const signature = spans.map((span) => `${span.from}:${span.to}:${span.color}:${span.fontStyle}`).join('|');
574
+ const signature = spans.map((span) => `${span.from}:${span.to}:${span.color}:${span.fontStyle}:${span.within ?? ''}`).join('|');
454
575
  const cached = this.semanticLines.get(line);
455
576
  if (cached?.base === base && cached.signature === signature) {
456
577
  return cached.result;
@@ -482,7 +603,7 @@ export class EditorView {
482
603
  continue;
483
604
  }
484
605
  const list = lines.get(line) ?? [];
485
- list.push({ from: token.from - start, to: token.to - start, color: style.color, fontStyle: style.fontStyle });
606
+ list.push({ from: token.from - start, to: token.to - start, color: style.color, fontStyle: style.fontStyle, within: token.within });
486
607
  lines.set(line, list);
487
608
  }
488
609
  for (const list of lines.values()) {
@@ -513,6 +634,45 @@ export class EditorView {
513
634
  }, NOTICE_MS);
514
635
  }
515
636
 
637
+ private foldName(line: number, collapsed: boolean): string {
638
+ const template =
639
+ (collapsed ? this.settings.messages.unfold : this.settings.messages.fold) ??
640
+ (collapsed ? 'Unfold lines {start} to {end}' : 'Fold lines {start} to {end}');
641
+ const end = this.foldAt(line)?.endLine ?? line;
642
+ return template.replace('{start}', String(line + 1)).replace('{end}', String(end + 1));
643
+ }
644
+
645
+ /* A new child each time, since a region only speaks what is added to it and a repeated sentence would otherwise stay silent. */
646
+ announce(message: string): void {
647
+ const sentence = this.document.createElement('span');
648
+ sentence.textContent = message;
649
+ this.live.replaceChildren(sentence);
650
+ clearTimeout(this.liveTimer);
651
+ this.liveTimer = setTimeout(() => this.live.replaceChildren(), NOTICE_MS);
652
+ }
653
+
654
+ setInputPopup(popup: EditorInputPopup | null): void {
655
+ if (popup === null) {
656
+ for (const name of ['aria-expanded', 'aria-controls', 'aria-autocomplete', 'aria-activedescendant']) {
657
+ this.input.removeAttribute(name);
658
+ }
659
+ return;
660
+ }
661
+ this.input.setAttribute('aria-expanded', 'true');
662
+ this.input.setAttribute('aria-autocomplete', 'list');
663
+ this.input.setAttribute('aria-controls', popup.controls);
664
+ if (popup.activeDescendant === undefined) {
665
+ this.input.removeAttribute('aria-activedescendant');
666
+ } else {
667
+ this.input.setAttribute('aria-activedescendant', popup.activeDescendant);
668
+ }
669
+ }
670
+
671
+ setLineLabels(labels: ((line: number) => string | null) | null): void {
672
+ this.lineLabelProvider = labels;
673
+ this.render();
674
+ }
675
+
516
676
  get gutterElement(): HTMLElement {
517
677
  return this.gutter;
518
678
  }
@@ -529,6 +689,10 @@ export class EditorView {
529
689
  return this.document.activeElement === this.input;
530
690
  }
531
691
 
692
+ get contentHeight(): number {
693
+ return this.layout.height + Math.max(0, (this.viewport.offsetHeight ?? 0) - (this.viewport.clientHeight ?? 0));
694
+ }
695
+
532
696
  get viewportHeight(): number {
533
697
  return this.clientHeight || DEFAULT_HEIGHT;
534
698
  }
@@ -585,7 +749,14 @@ export class EditorView {
585
749
  this.outline.edited(batches);
586
750
  this.changeVersion++;
587
751
  this.tokens.edited(batches, (offset) => this.model.positionAt(offset).line);
588
- this.layout.configure({ textChanged: true, inlays: this.displayedInlays(), blocks: this.allBlocks(), folds: this.foldStates() });
752
+ this.layout.configure({
753
+ textChanged: true,
754
+ changes: batches,
755
+ inlays: this.displayedInlays(),
756
+ blocks: this.allBlocks(),
757
+ folds: this.foldStates(),
758
+ replacements: this.allReplacements()
759
+ });
589
760
  if (anchor && this.viewport.scrollTop > 0) {
590
761
  let offset = anchor.offset;
591
762
  for (const changes of batches) {
@@ -606,34 +777,46 @@ export class EditorView {
606
777
 
607
778
  /* Moves everything anchored to an offset along with one batch of changes. */
608
779
  private followEdit(changes: readonly DocumentChange[]): void {
609
- this.inlays = this.inlays.map((inlay) => ({ ...inlay, at: mapOffset(inlay.at, changes) }));
780
+ const map = offsetMapper(changes);
781
+ this.inlays = this.inlays.map((inlay) => ({ ...inlay, at: map(inlay.at) }));
610
782
  for (const [owner, blocks] of this.blocks) {
611
783
  this.blocks.set(
612
784
  owner,
613
- blocks.map((block) => ({ ...block, at: mapOffset(block.at, changes) }))
785
+ blocks.map((block) => ({ ...block, at: map(block.at) }))
614
786
  );
615
787
  }
616
- this.lenses = this.lenses.map((lens) => ({ ...lens, at: mapOffset(lens.at, changes) }));
617
- this.collapsed = new Set([...this.collapsed].map((anchor) => mapOffset(anchor, changes)));
618
- this.settled = new Set([...this.settled].map((anchor) => mapOffset(anchor, changes)));
788
+ this.lenses = this.lenses.map((lens) => ({ ...lens, at: map(lens.at) }));
789
+ for (const [owner, entries] of this.replacements) {
790
+ this.replacements.set(
791
+ owner,
792
+ entries.map((entry) => ({ ...entry, from: map(entry.from), to: map(entry.to) }))
793
+ );
794
+ }
795
+ this.collapsed = new Set([...this.collapsed].map((anchor) => map(anchor)));
796
+ this.settled = new Set([...this.settled].map((anchor) => map(anchor)));
619
797
  if (this.foldHints !== null) {
620
- this.foldHints = mapFoldHints(this.foldHints, changes);
798
+ this.foldHints = mapFoldHints(this.foldHints, map);
621
799
  }
622
- this.customFolds = this.customFolds.map((fold) => mapRange(fold, changes));
800
+ this.customFolds = this.customFolds.map((fold) => mapRange(fold, map));
623
801
  this.find.mapBounds(changes);
624
- this.changeMarks = this.changeMarks.map((mark) => mapRange(mark, changes));
625
- this.markers = this.markers.map((marker) => mapRange(marker, changes));
802
+ for (const [layer, marks] of this.changeLayers) {
803
+ this.changeLayers.set(
804
+ layer,
805
+ marks.map((mark) => mapRange(mark, map))
806
+ );
807
+ }
808
+ this.markers = this.markers.map((marker) => mapRange(marker, map));
626
809
  this.mapTracked(changes);
627
- this.attribution.map(changes);
628
- this.highlights.map(changes);
629
- this.remoteCarets = this.remoteCarets.map((caret) => ({ ...caret, at: mapOffset(caret.at, changes) }));
810
+ this.attribution.map(changes, map);
811
+ this.highlights.map(changes, map);
812
+ this.remoteCarets = this.remoteCarets.map((caret) => ({ ...caret, at: map(caret.at) }));
630
813
  for (const [owner, entries] of this.lineActions) {
631
814
  this.lineActions.set(
632
815
  owner,
633
- entries.map((entry) => ({ ...entry, at: mapOffset(entry.at, changes) }))
816
+ entries.map((entry) => ({ ...entry, at: map(entry.at) }))
634
817
  );
635
818
  }
636
- this.semantic = this.semantic.map((token) => mapRange(token, changes));
819
+ this.semantic = this.semantic.map((token) => mapRange(token, map));
637
820
  }
638
821
 
639
822
  private mapTracked(changes: readonly DocumentChange[]): void {
@@ -664,13 +847,14 @@ export class EditorView {
664
847
 
665
848
  /* The ranges follow the text until the next read of the document, so a fold above an edit stays closed meanwhile. */
666
849
  private mappedFolds(batches: readonly (readonly DocumentChange[])[]): ViewFold[] {
850
+ const mappers = batches.map(offsetMapper);
667
851
  const mapped: ViewFold[] = [];
668
852
  for (const range of this.foldRanges) {
669
853
  let from = range.from;
670
854
  let to = range.to;
671
- for (const changes of batches) {
672
- from = mapOffset(from, changes);
673
- to = mapOffset(to, changes);
855
+ for (const map of mappers) {
856
+ from = map(from);
857
+ to = map(to);
674
858
  }
675
859
  const startLine = this.model.positionAt(from).line;
676
860
  const endLine = this.model.positionAt(to).line;
@@ -712,7 +896,7 @@ export class EditorView {
712
896
  height: rest.length * this.layout.metrics.lineHeight,
713
897
  render: (container) => {
714
898
  container.classList.add('se-widget', 'se-ghost-rows');
715
- renderCodeBlock(container, rest.join('\n'), null, this.settings.tabSize, {});
899
+ renderCodeBlock(container, visibleBidi(rest.join('\n')), null, this.settings.tabSize, {});
716
900
  }
717
901
  }
718
902
  ]
@@ -756,12 +940,15 @@ export class EditorView {
756
940
  /* The folds of the document, a moment after the last edit since they read all of it. */
757
941
  private scheduleFolds(): void {
758
942
  clearTimeout(this.foldTimer);
943
+ if (this.settings.folding === false) {
944
+ return;
945
+ }
759
946
  this.foldTimer = setTimeout(() => this.refreshFolds(), FOLD_DELAY_MS);
760
947
  }
761
948
 
762
949
  /* Reads the folds off the document now, which otherwise waits for a pause in editing. */
763
950
  refreshFolds(): void {
764
- if (this.disposed) {
951
+ if (this.disposed || this.settings.folding === false) {
765
952
  return;
766
953
  }
767
954
  const ranges =
@@ -934,6 +1121,9 @@ export class EditorView {
934
1121
  * and closes one of the document's own. A selection inside one line has no line to fold.
935
1122
  */
936
1123
  foldSelection(): void {
1124
+ if (this.settings.folding === false) {
1125
+ return;
1126
+ }
937
1127
  const { anchor, head } = this.model.getPrimary();
938
1128
  let from = Math.min(anchor, head);
939
1129
  let to = Math.max(anchor, head);
@@ -985,6 +1175,9 @@ export class EditorView {
985
1175
  state: { collapsed: readonly { startLine: number; endLine: number }[]; custom: readonly { startLine: number; endLine: number }[] } | null,
986
1176
  defaults: readonly FoldRole[] = []
987
1177
  ): void {
1178
+ if (this.settings.folding === false) {
1179
+ return;
1180
+ }
988
1181
  const last = this.model.getLineCount() - 1;
989
1182
  if (state !== null) {
990
1183
  this.customFolds = state.custom
@@ -1085,6 +1278,10 @@ export class EditorView {
1085
1278
  /* Opens the folds that hide an offset, so a caret or a search result is where it can be seen. */
1086
1279
  ensureVisible(offset: number): void {
1087
1280
  const line = this.model.positionAt(offset).line;
1281
+ const row = this.layout.rowForLine(line);
1282
+ if (row.kind === 'text') {
1283
+ row.replacement?.onReveal?.();
1284
+ }
1088
1285
  if (!this.layout.hides(offset)) {
1089
1286
  return;
1090
1287
  }
@@ -1109,7 +1306,8 @@ export class EditorView {
1109
1306
  action();
1110
1307
  const next = this.layout.rows.find((row) => row.key === anchor.key);
1111
1308
  if (next) {
1112
- this.viewport.scrollTop = Math.max(0, next.top + offset);
1309
+ // Unwrapping can shrink the anchor below its old offset.
1310
+ this.viewport.scrollTop = Math.max(0, next.top + Math.min(offset, Math.max(0, next.height - 1)));
1113
1311
  }
1114
1312
  }
1115
1313
 
@@ -1290,6 +1488,26 @@ export class EditorView {
1290
1488
  return ranges;
1291
1489
  }
1292
1490
 
1491
+ private get paintWindow(): VerticalWindow {
1492
+ return { top: this.scrollTop - this.overscan, bottom: this.scrollTop + this.viewportHeight + this.overscan };
1493
+ }
1494
+
1495
+ private paintRectangles(from: number, to: number, rows: readonly LayoutRow[]): LayoutRect[] {
1496
+ return this.layout.rectangles(from, to, rows, this.paintWindow);
1497
+ }
1498
+
1499
+ private visibleTextBounds(row: TextRow): { from: number; to: number } {
1500
+ const geometry = this.layout.geometry(row);
1501
+ const band = subRowBand(row.top, geometry.subRows, this.layout.metrics.lineHeight, this.paintWindow);
1502
+ return {
1503
+ from: geometry.start + offsetOfStop(geometry, geometry.rowStarts[band.first]!),
1504
+ to:
1505
+ band.last + 1 === geometry.subRows
1506
+ ? this.model.getLine(row.line).next
1507
+ : geometry.start + offsetOfStop(geometry, geometry.rowStarts[band.last + 1]!)
1508
+ };
1509
+ }
1510
+
1293
1511
  /* The arrows of the soft wraps in the rows on screen. */
1294
1512
  private wrapSigns(rows: readonly LayoutRow[]): WrapSign[] {
1295
1513
  if (!this.layout.wrapping) {
@@ -1302,14 +1520,15 @@ export class EditorView {
1302
1520
  continue;
1303
1521
  }
1304
1522
  const geometry = this.layout.geometry(row);
1305
- for (let subRow = 0; subRow < geometry.subRows; subRow++) {
1523
+ const band = subRowBand(row.top, geometry.subRows, lineHeight, this.paintWindow);
1524
+ for (let subRow = band.first; subRow <= band.last; subRow++) {
1306
1525
  const y = row.top + subRow * lineHeight;
1307
1526
  if (subRow + 1 < geometry.subRows) {
1308
1527
  const x = geometry.rowEnds[subRow]!;
1309
1528
  signs.push({ x, y, width: this.viewportWidth - x });
1310
1529
  }
1311
1530
  if (subRow > 0) {
1312
- signs.push({ x: 0, y, width: geometry.before[geometry.rowStarts[subRow]!]! });
1531
+ signs.push({ x: 0, y, width: xOfStop(geometry, geometry.rowStarts[subRow]!) });
1313
1532
  }
1314
1533
  }
1315
1534
  }
@@ -1494,6 +1713,58 @@ export class EditorView {
1494
1713
  !this.settings.readOnly && event.buttons === 0 && this.selectionAtPoint(event.clientX, event.clientY) !== null
1495
1714
  );
1496
1715
  this.setHover(event.buttons === 0 ? this.characterAtPoint(event.clientX, event.clientY, event.target) : null);
1716
+ this.setInlayHover(event.buttons === 0 ? event.target : null);
1717
+ }
1718
+
1719
+ /* The hint, and the part of its label, an event happened on, or null anywhere else. */
1720
+ inlayAt(target: EventTarget | null): { id: string; part: number | null; element: HTMLElement } | null {
1721
+ const element = (target as HTMLElement | null)?.closest?.<HTMLElement>('.se-inlay');
1722
+ if (!element?.dataset.inlayId) {
1723
+ return null;
1724
+ }
1725
+ const part = (target as HTMLElement).closest<HTMLElement>('[data-part]');
1726
+ return { id: element.dataset.inlayId, part: part === null ? null : Number(part.dataset.part), element: part ?? element };
1727
+ }
1728
+
1729
+ /* Says which hint the pointer is on, or that it left them all. */
1730
+ setInlayHover(target: EventTarget | null): void {
1731
+ const found = this.inlayAt(target);
1732
+ if (found?.id === this.inlayHover?.id && found?.part === this.inlayHover?.part) {
1733
+ return;
1734
+ }
1735
+ this.inlayHover = found === null ? null : { id: found.id, part: found.part };
1736
+ emit(this.inlayHoverListeners, found === null ? null : { id: found.id, part: found.part, rect: found.element.getBoundingClientRect() });
1737
+ }
1738
+
1739
+ onInlayHover(listener: (target: { id: string; part: number | null; rect: EditorRect } | null) => void): () => void {
1740
+ return subscribe(this.inlayHoverListeners, listener);
1741
+ }
1742
+
1743
+ /* Draws a hint or one of its parts as a link, which only the pill on screen can show. The target outlives a repaint of its line. */
1744
+ setInlayLink(target: { id: string; part: number | null } | null): void {
1745
+ this.inlayLink = target === null ? null : { id: target.id, part: target.part };
1746
+ this.applyInlayLink();
1747
+ }
1748
+
1749
+ private applyInlayLink(): void {
1750
+ const target = this.inlayLink;
1751
+ if (target === null && this.inlayLinkElement === null) {
1752
+ return;
1753
+ }
1754
+ let element: HTMLElement | undefined;
1755
+ if (target !== null) {
1756
+ const pill = Array.from(this.content.querySelectorAll<HTMLElement>('[data-inlay-id]')).find((candidate) => candidate.dataset.inlayId === target.id);
1757
+ element =
1758
+ target.part === null
1759
+ ? pill
1760
+ : (Array.from(pill?.querySelectorAll<HTMLElement>('[data-part]') ?? []).find((part) => part.dataset.part === String(target.part)) ?? pill);
1761
+ }
1762
+ if (element === this.inlayLinkElement) {
1763
+ return;
1764
+ }
1765
+ this.inlayLinkElement?.classList.remove('se-inlay-link');
1766
+ element?.classList.add('se-inlay-link');
1767
+ this.inlayLinkElement = element ?? null;
1497
1768
  }
1498
1769
 
1499
1770
  setHover(offset: number | null): void {
@@ -1504,6 +1775,30 @@ export class EditorView {
1504
1775
  emit(this.hoverListeners, offset);
1505
1776
  }
1506
1777
 
1778
+ getLineHeight(line: number): number {
1779
+ if (!Number.isInteger(line) || line < 0 || line >= this.model.getLineCount() || this.layout.isHidden(line)) {
1780
+ return 0;
1781
+ }
1782
+ return this.layout.rowForLine(line).height;
1783
+ }
1784
+
1785
+ onLineHeights(listener: () => void): () => void {
1786
+ return subscribe(this.lineHeightListeners, listener);
1787
+ }
1788
+
1789
+ setScrollTop(top: number): void {
1790
+ if (!Number.isFinite(top)) {
1791
+ return;
1792
+ }
1793
+ this.cancelScroll();
1794
+ const next = Math.max(0, top);
1795
+ if (this.viewport.scrollTop === next) {
1796
+ return;
1797
+ }
1798
+ this.viewport.scrollTop = next;
1799
+ this.render();
1800
+ }
1801
+
1507
1802
  onViewChange(listener: () => void): () => void {
1508
1803
  return subscribe(this.viewListeners, listener);
1509
1804
  }
@@ -1618,37 +1913,32 @@ export class EditorView {
1618
1913
  /* What a regular expression replacement writes for the match the find is on, drawn under that match; null takes it away. */
1619
1914
  setReplacePreview(replacement: string | null, preserveCase = false): void {
1620
1915
  this.replacePreview = replacement === null ? null : { text: replacement, preserveCase };
1916
+ this.find.setPreview(replacement !== null);
1621
1917
  this.paintPreview();
1622
1918
  }
1623
1919
 
1624
1920
  /* Shown only when it says something the replacement text does not, as a regular expression's does. */
1625
1921
  private paintPreview(): void {
1626
- const mark = this.find.currentMark;
1627
- const query = this.find.activeQuery;
1922
+ const match = this.find.currentMatch;
1628
1923
  const wanted = this.replacePreview;
1629
1924
  let shown: string | null = null;
1630
- if (wanted !== null && mark !== null && query?.regex === true) {
1925
+ if (wanted !== null && match?.regex === true) {
1631
1926
  try {
1632
- const match = this.model.find(query.text, {
1633
- caseSensitive: query.caseSensitive,
1634
- wholeWord: query.wholeWord,
1635
- regex: true,
1636
- from: mark.from,
1637
- to: mark.to,
1638
- maxResults: 1
1639
- })[0];
1640
- const text = match === undefined ? wanted.text : replacementText(this.model.getText(), match, wanted.text, false, wanted.preserveCase);
1927
+ if (wanted.text.length > 10000) {
1928
+ throw new RangeError('The replacement preview is too large.');
1929
+ }
1930
+ const text = replacementText(this.model.getText(), match, wanted.text, false, wanted.preserveCase, 10000);
1641
1931
  shown = text === wanted.text ? null : text;
1642
1932
  } catch {
1643
1933
  shown = null;
1644
1934
  }
1645
1935
  }
1646
1936
  this.preview.hidden = shown === null;
1647
- if (shown === null || mark === null) {
1937
+ if (shown === null || match === null) {
1648
1938
  return;
1649
1939
  }
1650
- const start = this.layout.caret(mark.from);
1651
- const end = this.layout.caret(mark.to);
1940
+ const start = this.layout.caret(match.from);
1941
+ const end = this.layout.caret(match.to);
1652
1942
  this.preview.textContent = shown;
1653
1943
  this.preview.style.left = `${(start.x + end.x) / 2}px`;
1654
1944
  this.preview.style.top = `${end.y + end.height}px`;
@@ -1687,7 +1977,7 @@ export class EditorView {
1687
1977
 
1688
1978
  /* Colors the lines on and just past the screen, a slice at a time so typing never waits on a grammar. */
1689
1979
  colorAhead(immediate = false): void {
1690
- if (this.disposed || !this.tokens.ready) {
1980
+ if (this.disposed || this.lineTokenProvider !== null || !this.tokens.ready) {
1691
1981
  return;
1692
1982
  }
1693
1983
  clearTimeout(this.colorTimer);
@@ -1707,9 +1997,16 @@ export class EditorView {
1707
1997
  );
1708
1998
  }
1709
1999
 
1710
- /* The gutter is as wide as its widest number needs, which only changes when the line count gains a digit. */
2000
+ /* Source numbering can be much larger than the display document's line count. */
1711
2001
  private syncGutter(): void {
1712
- const digits = String(this.model.getLineCount()).length;
2002
+ if (this.lineNumberProvider !== null && this.lineNumberRevision !== this.model.getRevision()) {
2003
+ const provider = this.lineNumberProvider;
2004
+ this.lineNumberLabels = Array.from({ length: this.model.getLineCount() }, (_, line) => provider(line));
2005
+ this.lineNumberWidth = this.lineNumberLabels.reduce((width, label) => Math.max(width, label?.length ?? 0), 0);
2006
+ this.lineNumberRevision = this.model.getRevision();
2007
+ this.stickyEntries = [];
2008
+ }
2009
+ const digits = this.lineNumberProvider === null ? String(this.model.getLineCount()).length : this.lineNumberWidth;
1713
2010
  const width = Math.max(MIN_GUTTER_WIDTH, Math.ceil(digits * this.layout.metrics.charWidth) + 40);
1714
2011
  if (width !== this.gutterWidth) {
1715
2012
  this.gutterWidth = width;
@@ -1765,7 +2062,7 @@ export class EditorView {
1765
2062
  const lastLine = rows.at(-1)?.line ?? 0;
1766
2063
  const firstRow = this.layout.rowAt(this.scrollTop);
1767
2064
  this.topAnchor = { offset: this.model.getLine(firstRow.line).start, delta: this.scrollTop - firstRow.top };
1768
- if (lastLine - this.tokens.colored <= COLOR_MARGIN * 4) {
2065
+ if (this.lineTokenProvider === null && lastLine - this.tokens.colored <= COLOR_MARGIN * 4) {
1769
2066
  this.tokens.advance(lastLine, FIRST_PAINT_MS);
1770
2067
  }
1771
2068
  const paint = {
@@ -1773,6 +2070,7 @@ export class EditorView {
1773
2070
  whitespace: this.settings.whitespace,
1774
2071
  tokensOf: (line: number) => this.styledTokensOf(line),
1775
2072
  viewportWidth: this.viewportWidth - RIGHT_PADDING,
2073
+ window: this.paintWindow,
1776
2074
  hostLeft: this.scrollLeft - this.gutterWidth,
1777
2075
  hostWidth: this.viewportWidth + this.gutterWidth,
1778
2076
  onUnfold: (line: number) => this.toggleFold(line, false)
@@ -1781,6 +2079,7 @@ export class EditorView {
1781
2079
  rows = this.layout.visibleRows(this.scrollTop, this.viewportHeight, this.overscan);
1782
2080
  this.painter.paint(rows, paint);
1783
2081
  }
2082
+ this.applyInlayLink();
1784
2083
  this.content.style.height = `${this.layout.height}px`;
1785
2084
  this.content.style.width = `${Math.max(this.layout.width, this.viewportWidth)}px`;
1786
2085
  this.renderedScroll = { top: this.scrollTop, left: this.scrollLeft };
@@ -1800,6 +2099,11 @@ export class EditorView {
1800
2099
  this.frameGeometry = null;
1801
2100
  this.rendering = false;
1802
2101
  }
2102
+ if (!this.disposed && this.announcedLineHeights !== this.layout.lineHeightsVersion) {
2103
+ this.announcedLineHeights = this.layout.lineHeightsVersion;
2104
+ // Hosts may adjust rows in response; rendering must already have finished.
2105
+ emit(this.lineHeightListeners);
2106
+ }
1803
2107
  }
1804
2108
 
1805
2109
  private paintDecorations(rows: readonly LayoutRow[]): void {
@@ -1815,11 +2119,14 @@ export class EditorView {
1815
2119
  const lastLine = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
1816
2120
  const highlighted = this.highlightedLines(firstLine, lastLine);
1817
2121
  paintGutter(this.gutterLines, this.layout, rows, {
2122
+ lineNumber: (line) => this.lineNumberLabel(line),
1818
2123
  changes: this.changedLines(rows),
1819
2124
  attribution: this.attributedLines(firstLine, lastLine),
1820
2125
  highlights: highlighted,
1821
2126
  activeLines: new Set(selections.map((selection) => this.model.positionAt(selection.head).line)),
1822
2127
  foldable,
2128
+ lineLabel: (line) => this.lineLabelProvider?.(line) ?? null,
2129
+ foldName: (line, collapsed) => this.foldName(line, collapsed),
1823
2130
  action: this.gutterAction,
1824
2131
  markers: this.markersByLine()
1825
2132
  });
@@ -1834,7 +2141,7 @@ export class EditorView {
1834
2141
  if (group.length > 0) {
1835
2142
  marks.push({
1836
2143
  className: kind === 'write' ? 'se-occurrence se-occurrence-write' : 'se-occurrence',
1837
- rects: group.flatMap((mark) => this.layout.rectangles(mark.from, mark.to, rows))
2144
+ rects: group.flatMap((mark) => this.paintRectangles(mark.from, mark.to, rows))
1838
2145
  });
1839
2146
  }
1840
2147
  }
@@ -1850,7 +2157,7 @@ export class EditorView {
1850
2157
  paintSigns(this.signs, this.wrapSigns(rows), this.layout.metrics.lineHeight);
1851
2158
  const alike = this.selectionOccurrences();
1852
2159
  if (alike.length > 0) {
1853
- marks.push({ className: 'se-occurrence', rects: alike.flatMap((range) => this.layout.rectangles(range.from, range.to, rows)) });
2160
+ marks.push({ className: 'se-occurrence', rects: alike.flatMap((range) => this.paintRectangles(range.from, range.to, rows)) });
1854
2161
  }
1855
2162
  const faded: LayoutRect[] = [];
1856
2163
  const struck: LayoutRect[] = [];
@@ -1858,7 +2165,7 @@ export class EditorView {
1858
2165
  if (marker.to <= marker.from) {
1859
2166
  continue;
1860
2167
  }
1861
- const rects = this.layout.rectangles(marker.from, marker.to, rows);
2168
+ const rects = this.paintRectangles(marker.from, marker.to, rows);
1862
2169
  if (marker.severity !== 'hint') {
1863
2170
  marks.push({ className: `se-squiggle se-squiggle-${marker.severity}`, rects });
1864
2171
  }
@@ -1871,11 +2178,22 @@ export class EditorView {
1871
2178
  }
1872
2179
  const current = this.find.currentMark;
1873
2180
  const textRows = rows.filter((candidate) => candidate.kind === 'text');
1874
- const visibleFrom = textRows.length > 0 ? this.model.getLine(textRows[0]!.line).start : 0;
1875
- const visibleTo = textRows.length > 0 ? this.model.getLine(textRows.at(-1)!.line).next : 0;
1876
- for (const mark of this.find.matches) {
1877
- if (mark.to > mark.from && mark.to >= visibleFrom && mark.from <= visibleTo) {
1878
- marks.push({ className: mark === current ? 'se-find-current' : 'se-find-match', rects: this.layout.rectangles(mark.from, mark.to, rows) });
2181
+ const visibleFrom = textRows.length > 0 ? this.visibleTextBounds(textRows[0]!).from : 0;
2182
+ const visibleTo = textRows.length > 0 ? this.visibleTextBounds(textRows.at(-1)!).to : 0;
2183
+ const found = this.find.matchesBetween(visibleFrom, visibleTo);
2184
+ const spans =
2185
+ current !== null &&
2186
+ !found.some((mark) => mark.from === current.from && mark.to === current.to) &&
2187
+ current.to >= visibleFrom &&
2188
+ current.from <= visibleTo
2189
+ ? [...found, current]
2190
+ : found;
2191
+ for (const mark of spans) {
2192
+ if (mark.to > mark.from) {
2193
+ marks.push({
2194
+ className: mark.from === current?.from && mark.to === current.to ? 'se-find-current' : 'se-find-match',
2195
+ rects: this.paintRectangles(mark.from, mark.to, rows)
2196
+ });
1879
2197
  }
1880
2198
  }
1881
2199
  const seen = new Set<number>();
@@ -1886,27 +2204,27 @@ export class EditorView {
1886
2204
  }
1887
2205
  if (bracket.mate === undefined) {
1888
2206
  if (this.unmatchedBracket(bracket.at)) {
1889
- marks.push({ className: 'se-bracket-unmatched', rects: this.layout.rectangles(bracket.at, bracket.at + 1, rows) });
2207
+ marks.push({ className: 'se-bracket-unmatched', rects: this.paintRectangles(bracket.at, bracket.at + 1, rows) });
1890
2208
  }
1891
2209
  continue;
1892
2210
  }
1893
2211
  for (const offset of [bracket.at, bracket.mate]) {
1894
2212
  if (!seen.has(offset)) {
1895
2213
  seen.add(offset);
1896
- marks.push({ className: 'se-bracket-match', rects: this.layout.rectangles(offset, offset + 1, rows) });
2214
+ marks.push({ className: 'se-bracket-match', rects: this.paintRectangles(offset, offset + 1, rows) });
1897
2215
  }
1898
2216
  }
1899
2217
  }
1900
2218
  const selected = selections.flatMap((selection) =>
1901
2219
  selection.anchor === selection.head
1902
2220
  ? []
1903
- : this.layout.rectangles(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), rows)
2221
+ : this.paintRectangles(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), rows)
1904
2222
  );
1905
2223
  paintOverlays(this.overlays, { currentLine, selections: selected, focused, marks });
1906
2224
  paintOver(this.over, [
1907
2225
  { className: 'se-faded', rects: faded },
1908
2226
  { className: 'se-struck', rects: struck },
1909
- { className: 'se-link', rects: this.link === null ? [] : this.layout.rectangles(this.link.from, this.link.to, rows) }
2227
+ { className: 'se-link', rects: this.link === null ? [] : this.paintRectangles(this.link.from, this.link.to, rows) }
1910
2228
  ]);
1911
2229
  const top = this.scrollTop;
1912
2230
  const caretRects = focused
@@ -1929,38 +2247,84 @@ export class EditorView {
1929
2247
  this.root.dataset.carets = String(selections.length);
1930
2248
  }
1931
2249
 
1932
- /* The lines the host marked, which stay on their lines through edits until it marks them again. */
1933
- setChangeMarks(marks: readonly EditorChangeMark[]): void {
2250
+ /*
2251
+ * The lines the host marked in a layer, which stay on their lines through edits until the layer is set again. Layers
2252
+ * draw together; where two mark the same line the layer that was set first after being empty sits below the later one.
2253
+ */
2254
+ setChangeMarks(marks: readonly EditorChangeMark[], layer = ''): void {
1934
2255
  const last = this.model.getLineCount();
1935
2256
  const clamp = (line: number): number => Math.min(last, Math.max(1, Math.trunc(line) || 1)) - 1;
1936
- this.changeMarks = marks.map((mark) => {
1937
- const from = this.model.getLine(clamp(mark.startLine)).start;
1938
- return { kind: mark.kind, from, to: mark.kind === 'deleted' ? from : this.model.getLine(Math.max(clamp(mark.startLine), clamp(mark.endLine))).end };
1939
- });
2257
+ if (marks.length === 0) {
2258
+ this.changeLayers.delete(layer);
2259
+ } else {
2260
+ this.changeLayers.set(
2261
+ layer,
2262
+ marks.map((mark) => {
2263
+ const from = this.model.getLine(clamp(mark.startLine)).start;
2264
+ return {
2265
+ kind: mark.kind,
2266
+ from,
2267
+ to: mark.kind === 'deleted' ? from : this.model.getLine(Math.max(clamp(mark.startLine), clamp(mark.endLine))).end
2268
+ };
2269
+ })
2270
+ );
2271
+ }
1940
2272
  this.changeVersion++;
1941
2273
  this.render();
1942
2274
  }
1943
2275
 
1944
- private markedLines(mark: { kind: EditorChangeKind; from: number; to: number }): { start: number; end: number } {
1945
- const start = this.model.positionAt(mark.from).line;
1946
- return { start, end: mark.kind === 'deleted' ? start : this.model.positionAt(Math.max(mark.from, mark.to)).line };
2276
+ /*
2277
+ * How every marked line differs. The layer that last became non-empty wins a shared line for every kind; inside one
2278
+ * layer a removal never covers a line that was added or changed, as its mark sits on the line after the removed text.
2279
+ */
2280
+ private resolvedChanges(): { lines: Map<number, EditorChangeKind>; runs: { kind: EditorChangeKind; start: number; end: number }[] } {
2281
+ if (this.changeCache?.version === this.changeVersion) {
2282
+ return this.changeCache;
2283
+ }
2284
+ const lines = new Map<number, EditorChangeKind>();
2285
+ for (const marks of this.changeLayers.values()) {
2286
+ const layer = new Map<number, EditorChangeKind>();
2287
+ for (const mark of marks) {
2288
+ const start = this.model.positionAt(mark.from).line;
2289
+ const end = mark.kind === 'deleted' ? start : this.model.positionAt(Math.max(mark.from, mark.to)).line;
2290
+ for (let line = start; line <= end; line++) {
2291
+ if (mark.kind !== 'deleted' || !layer.has(line)) {
2292
+ layer.set(line, mark.kind);
2293
+ }
2294
+ }
2295
+ }
2296
+ for (const [line, kind] of layer) {
2297
+ lines.set(line, kind);
2298
+ }
2299
+ }
2300
+ const runs: { kind: EditorChangeKind; start: number; end: number }[] = [];
2301
+ for (const line of [...lines.keys()].sort((left, right) => left - right)) {
2302
+ const kind = lines.get(line)!;
2303
+ const run = runs.at(-1);
2304
+ if (run !== undefined && kind !== 'deleted' && run.kind === kind && run.end === line - 1) {
2305
+ run.end = line;
2306
+ } else {
2307
+ runs.push({ kind, start: line, end: line });
2308
+ }
2309
+ }
2310
+ this.changeCache = { version: this.changeVersion, lines, runs };
2311
+ return this.changeCache;
1947
2312
  }
1948
2313
 
1949
- /* How each line on screen differs, a removal never covering a line that was added or changed. */
2314
+ /* How each line on screen differs. */
1950
2315
  private changedLines(rows: readonly LayoutRow[]): Map<number, EditorChangeKind> {
1951
- const lines = new Map<number, EditorChangeKind>();
2316
+ const { lines } = this.resolvedChanges();
1952
2317
  const first = rows[0]?.line ?? 0;
1953
2318
  const lastRow = rows.at(-1);
1954
2319
  const last = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
1955
- for (const mark of this.changeMarks) {
1956
- const { start, end } = this.markedLines(mark);
1957
- for (let line = Math.max(start, first); line <= Math.min(end, last); line++) {
1958
- if (mark.kind !== 'deleted' || !lines.has(line)) {
1959
- lines.set(line, mark.kind);
1960
- }
2320
+ const visible = new Map<number, EditorChangeKind>();
2321
+ for (let line = first; line <= last; line++) {
2322
+ const kind = lines.get(line);
2323
+ if (kind !== undefined) {
2324
+ visible.set(line, kind);
1961
2325
  }
1962
2326
  }
1963
- return lines;
2327
+ return visible;
1964
2328
  }
1965
2329
 
1966
2330
  /* The carets of agents, each at an offset that follows its text through edits until the host sets them again. */
@@ -2054,7 +2418,7 @@ export class EditorView {
2054
2418
  }
2055
2419
 
2056
2420
  /* Lines the host tinted, which follow their text through edits until it sets them again. */
2057
- setLineHighlights(highlights: readonly { startLine: number; endLine: number; color: string; sign?: string; fill?: string }[]): void {
2421
+ setLineHighlights(highlights: readonly EditorLineHighlight[]): void {
2058
2422
  this.highlights.set(
2059
2423
  highlights.map((highlight) => ({ id: '', ...highlight })),
2060
2424
  this.model.getLineCount(),
@@ -2080,6 +2444,7 @@ export class EditorView {
2080
2444
  ): { className: string; rects: LayoutRect[]; background: string }[] {
2081
2445
  const byBackground = new Map<string, LayoutRect[]>();
2082
2446
  const width = Math.max(this.layout.width, this.viewportWidth);
2447
+ const emphasisByColor = new Map<string, LayoutRect[]>();
2083
2448
  for (const row of rows) {
2084
2449
  const highlight = row.kind === 'text' ? lines.get(row.line) : undefined;
2085
2450
  if (highlight !== undefined) {
@@ -2087,14 +2452,32 @@ export class EditorView {
2087
2452
  const rects = byBackground.get(background) ?? [];
2088
2453
  rects.push({ x: 0, y: row.top, width, height: row.height });
2089
2454
  byBackground.set(background, rects);
2455
+ if (row.kind === 'text' && highlight.emphasis !== undefined) {
2456
+ const bounds = this.model.getLine(row.line);
2457
+ const emphasisBackground = `color-mix(in srgb, ${colorValue(highlight.color)} 22%, transparent)`;
2458
+ const emphasized = emphasisByColor.get(emphasisBackground) ?? [];
2459
+ for (const range of highlight.emphasis) {
2460
+ emphasized.push(...this.paintRectangles(Math.max(bounds.start, range.from), Math.min(bounds.end, range.to), [row]));
2461
+ }
2462
+ emphasisByColor.set(emphasisBackground, emphasized);
2463
+ }
2090
2464
  }
2091
2465
  }
2092
- return [...byBackground].map(([background, rects]) => ({ className: 'se-line-highlight', rects, background }));
2466
+ return [
2467
+ ...[...byBackground].map(([background, rects]) => ({ className: 'se-line-highlight', rects, background })),
2468
+ ...[...emphasisByColor].map(([background, rects]) => ({ className: 'se-line-emphasis', rects, background }))
2469
+ ];
2093
2470
  }
2094
2471
 
2095
2472
  /* Code that is not in the document, drawn in the editor's own face and colors. */
2096
2473
  renderCode(container: HTMLElement, text: string, options: EditorCodeBlockOptions): void {
2097
- renderCodeBlock(container, text, this.tokens.tokenizeText(text.split(/\r?\n/)), this.settings.tabSize, options);
2474
+ renderCodeBlock(
2475
+ container,
2476
+ text,
2477
+ options.tokens === undefined ? this.tokens.tokenizeText(text.split(/\r?\n/)) : options.tokens,
2478
+ this.settings.tabSize,
2479
+ options
2480
+ );
2098
2481
  }
2099
2482
 
2100
2483
  private attributedLines(first: number, last: number): Map<number, AttributedLines> {
@@ -2213,9 +2596,8 @@ export class EditorView {
2213
2596
  const row = this.layout.rowForLine(line);
2214
2597
  return row.top + row.height;
2215
2598
  };
2216
- for (const mark of this.changeMarks) {
2217
- const { start, end } = this.markedLines(mark);
2218
- spans.push({ kind: mark.kind, top: rowTop(start), bottom: mark.kind === 'deleted' ? rowTop(start) : rowBottom(end) });
2599
+ for (const { kind, start, end } of this.resolvedChanges().runs) {
2600
+ spans.push({ kind, top: rowTop(start), bottom: kind === 'deleted' ? rowTop(start) : rowBottom(end) });
2219
2601
  }
2220
2602
  for (const marker of this.markers) {
2221
2603
  if (marker.severity !== 'hint' && marker.to > marker.from) {
@@ -2297,13 +2679,18 @@ export class EditorView {
2297
2679
  const placements = this.scrollTop <= 0 ? [] : this.stickyAt(this.scrollTop);
2298
2680
  const entries = placements.map((placement): StickyEntry => {
2299
2681
  const row = this.layout.rowForLine(placement.block.startLine) as TextRow;
2300
- return { line: placement.block.startLine, geometry: this.layout.geometry(row), tokens: this.styledTokensOf(placement.block.startLine) };
2682
+ return {
2683
+ line: placement.block.startLine,
2684
+ label: this.lineNumberLabel(placement.block.startLine),
2685
+ geometry: this.layout.geometry(row),
2686
+ tokens: this.styledTokensOf(placement.block.startLine)
2687
+ };
2301
2688
  });
2302
2689
  const changed =
2303
2690
  entries.length !== this.stickyEntries.length ||
2304
2691
  entries.some((entry, index) => {
2305
2692
  const before = this.stickyEntries[index]!;
2306
- return entry.line !== before.line || entry.geometry !== before.geometry || entry.tokens !== before.tokens;
2693
+ return entry.line !== before.line || entry.label !== before.label || entry.geometry !== before.geometry || entry.tokens !== before.tokens;
2307
2694
  });
2308
2695
  this.sticky.hidden = entries.length === 0;
2309
2696
  this.sticky.style.setProperty('--se-scroll-left', `${this.scrollLeft}px`);
@@ -2343,11 +2730,18 @@ export class EditorView {
2343
2730
  return subscribe(this.gutterActionListeners, listener);
2344
2731
  }
2345
2732
 
2346
- setGutterMarkers(owner: string, markers: readonly { id: string; line: number; label: string }[]): void {
2733
+ setGutterMarkers(owner: string, markers: readonly GutterMarker[]): void {
2347
2734
  const before = this.gutterMarkers.get(owner) ?? [];
2348
2735
  if (
2349
2736
  before.length === markers.length &&
2350
- before.every((marker, index) => marker.id === markers[index]!.id && marker.line === markers[index]!.line && marker.label === markers[index]!.label)
2737
+ before.every(
2738
+ (marker, index) =>
2739
+ marker.id === markers[index]!.id &&
2740
+ marker.line === markers[index]!.line &&
2741
+ marker.label === markers[index]!.label &&
2742
+ marker.icon === markers[index]!.icon &&
2743
+ marker.pressed === markers[index]!.pressed
2744
+ )
2351
2745
  ) {
2352
2746
  return;
2353
2747
  }
@@ -2374,12 +2768,12 @@ export class EditorView {
2374
2768
  }
2375
2769
 
2376
2770
  /* One marker per line: where two owners mark the same line, the owner that sorts first stays. */
2377
- private markersByLine(): ReadonlyMap<number, { id: string; label: string }> {
2378
- const byLine = new Map<number, { id: string; label: string }>();
2771
+ private markersByLine(): ReadonlyMap<number, GutterMarker> {
2772
+ const byLine = new Map<number, GutterMarker>();
2379
2773
  for (const owner of [...this.gutterMarkers.keys()].sort()) {
2380
2774
  for (const marker of this.gutterMarkers.get(owner)!) {
2381
2775
  if (!byLine.has(marker.line)) {
2382
- byLine.set(marker.line, { id: marker.id, label: marker.label });
2776
+ byLine.set(marker.line, marker);
2383
2777
  }
2384
2778
  }
2385
2779
  }
@@ -2408,12 +2802,14 @@ export class EditorView {
2408
2802
  }
2409
2803
  this.disposed = true;
2410
2804
  this.cancelScroll();
2805
+ this.find.dispose();
2411
2806
  this.subscription.dispose();
2412
2807
  this.resizeObserver?.disconnect();
2413
2808
  this.blockObserver?.disconnect();
2414
2809
  clearTimeout(this.foldTimer);
2415
2810
  clearTimeout(this.colorTimer);
2416
2811
  clearTimeout(this.noticeTimer);
2812
+ clearTimeout(this.liveTimer);
2417
2813
  clearTimeout(this.caretTimer);
2418
2814
  if (this.frame !== undefined) {
2419
2815
  this.document.defaultView?.cancelAnimationFrame?.(this.frame);
@@ -2421,7 +2817,9 @@ export class EditorView {
2421
2817
  this.findListeners.clear();
2422
2818
  this.scopeListeners.clear();
2423
2819
  this.viewListeners.clear();
2820
+ this.lineHeightListeners.clear();
2424
2821
  this.hoverListeners.clear();
2822
+ this.inlayHoverListeners.clear();
2425
2823
  this.gutterActionListeners.clear();
2426
2824
  this.gutterMarkerListeners.clear();
2427
2825
  this.gutterMarkers.clear();