@adecore/editor 0.22.1 → 0.23.1

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