@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/offsets.ts CHANGED
@@ -14,3 +14,27 @@ export function mapOffset(offset: number, changes: readonly DocumentChange[]): n
14
14
  }
15
15
  return offset + delta;
16
16
  }
17
+
18
+ /* Build once per sorted, nonoverlapping batch when many offsets need to follow it. */
19
+ export function offsetMapper(changes: readonly DocumentChange[]): (offset: number) => number {
20
+ const deltas = new Float64Array(changes.length + 1);
21
+ for (let index = 0; index < changes.length; index++) {
22
+ const change = changes[index]!;
23
+ deltas[index + 1] = deltas[index]! + change.insertedLength - (change.to - change.from);
24
+ }
25
+ return (offset) => {
26
+ let low = 0,
27
+ high = changes.length;
28
+ while (low < high) {
29
+ const middle = (low + high) >>> 1;
30
+ const change = changes[middle]!;
31
+ if (change.to < offset || (change.to === offset && change.from !== change.to)) {
32
+ low = middle + 1;
33
+ } else {
34
+ high = middle;
35
+ }
36
+ }
37
+ const change = changes[low];
38
+ return change !== undefined && change.from <= offset ? change.from + deltas[low]! + change.insertedLength : offset + deltas[low]!;
39
+ };
40
+ }
package/src/outline.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DocumentChange, FoldingRange } from '@adecore/editor-core';
2
- import { mapOffset } from './offsets.ts';
2
+ import { offsetMapper } from './offsets.ts';
3
3
  import type { EditorBlock } from './types.ts';
4
4
 
5
5
  /* A block with a header line, in zero-based lines and both ends inclusive. */
@@ -168,13 +168,14 @@ export class Outline {
168
168
  }
169
169
 
170
170
  edited(batches: readonly (readonly DocumentChange[])[]): void {
171
+ const mappers = batches.map(offsetMapper);
171
172
  const move = (entries: OutlineEntry[]): OutlineEntry[] =>
172
173
  entries.map((entry) => {
173
174
  let from = entry.from;
174
175
  let to = entry.to;
175
- for (const changes of batches) {
176
- from = mapOffset(from, changes);
177
- to = mapOffset(to, changes);
176
+ for (const map of mappers) {
177
+ from = map(from);
178
+ to = map(to);
178
179
  }
179
180
  return { ...entry, from, to };
180
181
  });
package/src/paint.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { type AttributedLines, colorValue } from './attribution.ts';
2
- import { type BlockWidget, type EditorLayout, type LayoutRect, type LayoutRow, type LineGeometry, scanLine, type TextRow } from './layout.ts';
3
- import type { EditorChangeKind, LineToken } from './types.ts';
1
+ import { offsetOfStop, rowOfStop, stopAtOffset, stopCount, xOfStop } from './geometry-stops.ts';
2
+ import { type AttributedLines, colorValue, tintValue } from './attribution.ts';
3
+ import {
4
+ type BlockReplacement,
5
+ type BlockWidget,
6
+ type EditorLayout,
7
+ type LayoutRect,
8
+ type LayoutRow,
9
+ type LineGeometry,
10
+ scanLine,
11
+ subRowBand,
12
+ type SubRowBand,
13
+ type VerticalWindow,
14
+ type TextRow
15
+ } from './layout.ts';
16
+ import type { EditorChangeKind, EditorGutterIcon, LineToken } from './types.ts';
17
+ import { BIDI_CONTROLS, BIDI_PATTERN, visibleBidi } from './bidi.ts';
4
18
 
5
19
  const ITALIC = 1;
6
20
  const BOLD = 2;
@@ -19,6 +33,7 @@ interface PaintedRow {
19
33
  tailTokens: readonly LineToken[] | null;
20
34
  /* The revision of the widget a block row was drawn from. */
21
35
  revision: number;
36
+ band?: SubRowBand;
22
37
  }
23
38
 
24
39
  /* The gap around a collapsed fold's placeholder, so it reads as `{…}` and not as three separate things. */
@@ -32,6 +47,7 @@ export interface RowPaint {
32
47
  whitespace: boolean;
33
48
  tokensOf(line: number): readonly LineToken[] | null;
34
49
  viewportWidth: number;
50
+ window: VerticalWindow;
35
51
  /* Where a row of the host's own DOM starts and how wide it is: the whole width of the editor, over the gutter, wherever the text is scrolled to. */
36
52
  hostLeft: number;
37
53
  hostWidth: number;
@@ -106,43 +122,66 @@ function fillRun(
106
122
  }
107
123
  }
108
124
 
109
- /* One line of text as absolutely placed runs and inlays. `firstRowOnly` leaves out what a wrapped line carries onto later visual lines. */
110
125
  export function lineElement(
111
126
  document: Document,
112
127
  geometry: LineGeometry,
113
128
  tokens: readonly LineToken[] | null,
114
129
  lineHeight: number,
115
- firstRowOnly: boolean,
130
+ band: SubRowBand,
116
131
  whitespace = false
117
132
  ): HTMLElement {
118
133
  const element = document.createElement('div');
119
134
  element.className = 'se-line';
120
135
  element.style.width = `${geometry.width}px`;
121
- for (const run of geometry.runs) {
122
- if (firstRowOnly && run.subRow > 0) {
123
- continue;
136
+ let first = 0;
137
+ let last = geometry.runs.length;
138
+ while (first < last) {
139
+ const middle = (first + last) >>> 1;
140
+ if (geometry.runs[middle]!.subRow < band.first) {
141
+ first = middle + 1;
142
+ } else {
143
+ last = middle;
144
+ }
145
+ }
146
+ for (let index = first; index < geometry.runs.length; index++) {
147
+ const run = geometry.runs[index]!;
148
+ if (run.subRow > band.last) {
149
+ break;
124
150
  }
125
151
  const span = document.createElement('span');
126
152
  span.className = 'se-run';
127
153
  span.style.left = `${run.x}px`;
128
154
  span.style.top = `${run.subRow * lineHeight}px`;
129
- fillRun(span, geometry, run.from, run.to, tokens, whitespace);
155
+ fillRun(span, geometry, geometry.start + run.from, geometry.start + run.to, tokens, whitespace);
130
156
  element.append(span);
131
157
  }
132
158
  if (whitespace) {
133
- appendTabSigns(element, geometry, lineHeight, firstRowOnly);
159
+ appendTabSigns(element, geometry, lineHeight, band);
134
160
  }
161
+ appendBidiSigns(element, geometry, lineHeight, band);
135
162
  for (const box of geometry.inlays) {
136
- if (firstRowOnly && box.subRow > 0) {
163
+ if (box.subRow < band.first || box.subRow > band.last) {
137
164
  continue;
138
165
  }
139
166
  const span = document.createElement('span');
140
167
  span.className = box.inlay.ghost ? 'se-ghost' : box.inlay.composition ? 'se-composition' : 'se-inlay';
141
168
  span.dataset.inlayId = box.inlay.id;
142
- span.textContent = box.inlay.text;
143
- span.style.left = `${box.x}px`;
169
+ const { parts } = box.inlay;
170
+ if (parts === undefined) {
171
+ span.textContent = visibleBidi(box.inlay.text);
172
+ } else {
173
+ parts.forEach((part, index) => {
174
+ const element = document.createElement('span');
175
+ element.className = 'se-inlay-part';
176
+ element.dataset.part = String(index);
177
+ element.textContent = visibleBidi(part);
178
+ span.append(element);
179
+ });
180
+ }
181
+ const gapLeft = box.inlay.gapLeft ?? 0;
182
+ span.style.left = `${box.x + gapLeft}px`;
144
183
  span.style.top = `${box.subRow * lineHeight}px`;
145
- span.style.width = `${box.width}px`;
184
+ span.style.width = `${box.width - gapLeft - (box.inlay.gapRight ?? 0)}px`;
146
185
  element.append(span);
147
186
  }
148
187
  return element;
@@ -177,7 +216,9 @@ export class RowPainter {
177
216
  }
178
217
  let measured = false;
179
218
  for (const row of rows) {
180
- if (row.kind === 'text') {
219
+ if (row.kind === 'text' && row.replacement !== undefined) {
220
+ measured = this.paintReplacement(row, row.replacement, paint) || measured;
221
+ } else if (row.kind === 'text') {
181
222
  this.paintText(row, paint);
182
223
  } else {
183
224
  measured = this.paintBlock(row, paint) || measured;
@@ -189,6 +230,11 @@ export class RowPainter {
189
230
  private paintText(row: TextRow, paint: RowPaint): void {
190
231
  const geometry = this.layout.geometry(row);
191
232
  const tokens = paint.tokensOf(row.line);
233
+ // Block boundaries keep a scroll within the band from rebuilding its text DOM.
234
+ const band =
235
+ geometry.subRows <= 64
236
+ ? { first: 0, last: geometry.subRows - 1 }
237
+ : subRowBand(row.top, geometry.subRows, this.layout.metrics.lineHeight, paint.window, 64);
192
238
  const folded = row.lastLine > row.line;
193
239
  const lastLine = folded && row.tail !== null ? this.layout.document.getLine(row.lastLine) : null;
194
240
  const tail = lastLine === null ? '' : lastLine.text.slice(row.tail! - lastLine.start);
@@ -202,9 +248,11 @@ export class RowPainter {
202
248
  entry.whitespace !== paint.whitespace ||
203
249
  entry.folded !== folded ||
204
250
  entry.tail !== tail ||
205
- entry.tailTokens !== tailTokens
251
+ entry.tailTokens !== tailTokens ||
252
+ entry.band?.first !== band.first ||
253
+ entry.band?.last !== band.last
206
254
  ) {
207
- const element = this.buildText(row, geometry, tokens, paint, tail, tailTokens);
255
+ const element = this.buildText(row, geometry, tokens, paint, tail, tailTokens, band);
208
256
  if (entry) {
209
257
  entry.element.replaceWith(element);
210
258
  } else {
@@ -219,7 +267,8 @@ export class RowPainter {
219
267
  folded,
220
268
  tail,
221
269
  tailTokens,
222
- revision: 0
270
+ revision: 0,
271
+ band
223
272
  };
224
273
  this.rows.set(row.key, entry);
225
274
  }
@@ -234,12 +283,13 @@ export class RowPainter {
234
283
  tokens: readonly LineToken[] | null,
235
284
  paint: RowPaint,
236
285
  tail: string,
237
- tailTokens: readonly LineToken[] | null
286
+ tailTokens: readonly LineToken[] | null,
287
+ band: SubRowBand
238
288
  ): HTMLElement {
239
289
  const document = this.code.ownerDocument;
240
290
  const lineHeight = this.layout.metrics.lineHeight;
241
- const element = lineElement(document, geometry, tokens, lineHeight, false, paint.whitespace);
242
- if (row.lastLine > row.line) {
291
+ const element = lineElement(document, geometry, tokens, lineHeight, band, paint.whitespace);
292
+ if (row.lastLine > row.line && band.last === geometry.subRows - 1) {
243
293
  const chip = document.createElement('button');
244
294
  chip.type = 'button';
245
295
  chip.className = 'se-fold-chip';
@@ -270,9 +320,42 @@ export class RowPainter {
270
320
  span.className = 'se-run';
271
321
  span.style.left = `${left + run.x}px`;
272
322
  span.style.top = `${(header.subRows - 1) * this.layout.metrics.lineHeight}px`;
273
- fillRun(span, geometry, run.from, run.to, tokens, false, lineStart);
323
+ fillRun(span, geometry, geometry.start + run.from, geometry.start + run.to, tokens, false, lineStart);
274
324
  element.append(span);
275
325
  }
326
+ appendBidiSigns(element, geometry, this.layout.metrics.lineHeight, { first: 0, last: 0 }, left, (header.subRows - 1) * this.layout.metrics.lineHeight);
327
+ }
328
+
329
+ /* The lines the host draws itself: an element of its own, indented as the first line is and as tall as its content. */
330
+ private paintReplacement(row: TextRow, replacement: BlockReplacement, paint: RowPaint): boolean {
331
+ const revision = replacement.revision ?? 0;
332
+ let entry = this.rows.get(row.key);
333
+ if (!entry) {
334
+ const element = this.code.ownerDocument.createElement('div');
335
+ element.className = 'se-block se-replacement';
336
+ element.dataset.rowKey = row.key;
337
+ element.dataset.replacementId = replacement.id;
338
+ element.setAttribute('role', 'group');
339
+ if (replacement.label !== undefined) {
340
+ element.setAttribute('aria-label', replacement.label);
341
+ }
342
+ replacement.render(element);
343
+ this.code.append(element);
344
+ entry = { element, text: '', tokens: null, layoutVersion: 0, whitespace: false, folded: false, tail: '', tailTokens: null, revision };
345
+ this.rows.set(row.key, entry);
346
+ } else if (entry.revision !== revision) {
347
+ entry.element.replaceChildren();
348
+ replacement.render(entry.element);
349
+ entry.revision = revision;
350
+ }
351
+ const indent = this.layout.indentX(row.line);
352
+ entry.element.style.top = `${row.top}px`;
353
+ entry.element.style.left = `${indent}px`;
354
+ entry.element.style.width = `${Math.max(80, paint.viewportWidth - indent)}px`;
355
+ entry.element.style.height = '';
356
+ this.observer?.observe(entry.element);
357
+ const height = entry.element.getBoundingClientRect?.().height ?? 0;
358
+ return height > 0 && this.layout.setMeasuredHeight(row.key, height);
276
359
  }
277
360
 
278
361
  private paintBlock(row: Extract<LayoutRow, { kind: 'block' }>, paint: RowPaint): boolean {
@@ -286,7 +369,7 @@ export class RowPainter {
286
369
  element.dataset.widgetId = widget.id;
287
370
  this.fillBlock(element, widget);
288
371
  this.code.append(element);
289
- if (!widget.lens) {
372
+ if (!widget.lens && !widget.fixed) {
290
373
  this.observer?.observe(element);
291
374
  }
292
375
  entry = { element, text: '', tokens: null, layoutVersion: 0, whitespace: false, folded: false, tail: '', tailTokens: null, revision };
@@ -308,6 +391,13 @@ export class RowPainter {
308
391
  } else {
309
392
  entry.element.style.width = `${Math.max(80, paint.viewportWidth)}px`;
310
393
  }
394
+ if (widget.fixed) {
395
+ this.observer?.unobserve(entry.element);
396
+ entry.element.style.height = `${row.height}px`;
397
+ return false;
398
+ }
399
+ entry.element.style.height = '';
400
+ this.observer?.observe(entry.element);
311
401
  const height = entry.element.getBoundingClientRect?.().height ?? 0;
312
402
  return height > 0 && this.layout.setMeasuredHeight(row.key, height);
313
403
  }
@@ -330,6 +420,7 @@ export class RowPainter {
330
420
  }
331
421
 
332
422
  export interface GutterPaint {
423
+ lineNumber(line: number): string | null;
333
424
  activeLines: ReadonlySet<number>;
334
425
  /* How the lines the host marked differ from what it compares against. */
335
426
  changes: ReadonlyMap<number, EditorChangeKind>;
@@ -339,10 +430,14 @@ export interface GutterPaint {
339
430
  highlights: ReadonlyMap<number, AttributedLines>;
340
431
  /* Lines a fold can start at, and whether each is collapsed. */
341
432
  foldable: ReadonlyMap<number, boolean>;
433
+ /* What the fold control of a line is called, from its line and whether it is collapsed. */
434
+ foldName(line: number, collapsed: boolean): string;
435
+ /* A sentence read with the line, which then stands in for the drawn number and sign. */
436
+ lineLabel(line: number): string | null;
342
437
  /* The line that carries the host's button, and what it is called. */
343
438
  action: { line: number; label: string } | null;
344
439
  /* The host's markers by line. */
345
- markers: ReadonlyMap<number, { id: string; label: string }>;
440
+ markers: ReadonlyMap<number, { id: string; label: string; icon?: EditorGutterIcon; pressed?: boolean }>;
346
441
  }
347
442
 
348
443
  function gutterButton(document: Document, className: string, label: string): HTMLButtonElement {
@@ -366,13 +461,33 @@ export function paintGutter(container: HTMLElement, layout: EditorLayout, rows:
366
461
  item.className = paint.activeLines.has(row.line) ? 'se-line-number se-active-number' : 'se-line-number';
367
462
  item.style.top = `${row.top}px`;
368
463
  item.style.height = `${layout.metrics.lineHeight}px`;
369
- const fill = paint.highlights.get(row.line)?.fill;
464
+ const highlight = paint.highlights.get(row.line);
465
+ const fill = highlight?.fill;
370
466
  if (fill !== undefined) {
371
467
  item.style.background = colorValue(fill);
372
468
  }
469
+ if (highlight?.bar !== undefined) {
470
+ item.classList.add('se-line-marked');
471
+ item.style.height = `${row.height}px`;
472
+ item.style.setProperty('--se-line', `${layout.metrics.lineHeight}px`);
473
+ item.style.setProperty('--se-mark', colorValue(highlight.color));
474
+ if (fill === undefined) {
475
+ item.style.setProperty('--se-tint', tintValue(highlight.color));
476
+ }
477
+ }
373
478
  const number = document.createElement('span');
374
- number.textContent = String(row.line + 1);
479
+ number.textContent = paint.lineNumber(row.line);
480
+ const label = paint.lineLabel(row.line);
481
+ if (label !== null) {
482
+ number.setAttribute('aria-hidden', 'true');
483
+ }
375
484
  item.append(number);
485
+ if (label !== null) {
486
+ const hidden = document.createElement('span');
487
+ hidden.className = 'se-sr-only';
488
+ hidden.textContent = label;
489
+ item.append(hidden);
490
+ }
376
491
  if (paint.action?.line === row.line) {
377
492
  const button = gutterButton(document, 'se-gutter-action', paint.action.label);
378
493
  button.dataset.gutterAction = String(row.line);
@@ -382,6 +497,12 @@ export function paintGutter(container: HTMLElement, layout: EditorLayout, rows:
382
497
  if (marker !== undefined) {
383
498
  const button = gutterButton(document, 'se-gutter-marker', marker.label);
384
499
  button.dataset.gutterMarker = marker.id;
500
+ if (marker.icon !== undefined) {
501
+ button.dataset.icon = marker.icon;
502
+ }
503
+ if (marker.pressed !== undefined) {
504
+ button.setAttribute('aria-pressed', String(marker.pressed));
505
+ }
385
506
  item.append(button);
386
507
  }
387
508
  const sign = paint.highlights.get(row.line)?.sign;
@@ -389,9 +510,18 @@ export function paintGutter(container: HTMLElement, layout: EditorLayout, rows:
389
510
  const mark = document.createElement('span');
390
511
  mark.className = 'se-line-sign';
391
512
  mark.textContent = sign;
513
+ if (label !== null) {
514
+ mark.setAttribute('aria-hidden', 'true');
515
+ }
392
516
  mark.style.color = colorValue(paint.highlights.get(row.line)!.color);
393
517
  item.append(mark);
394
518
  }
519
+ if (highlight?.bar !== undefined) {
520
+ const mark = document.createElement('span');
521
+ mark.className = `se-line-bar se-line-bar-${highlight.bar}`;
522
+ mark.setAttribute('aria-hidden', 'true');
523
+ item.append(mark);
524
+ }
395
525
  const bar = paint.attribution.get(row.line);
396
526
  if (bar) {
397
527
  const mark = document.createElement('span');
@@ -403,7 +533,7 @@ export function paintGutter(container: HTMLElement, layout: EditorLayout, rows:
403
533
  }
404
534
  const change = paint.changes.get(row.line);
405
535
  // The bar of an agent stands where the mark of a change would, and a removal is a triangle between lines that the bar never hides.
406
- if (change && (!bar || change === 'deleted')) {
536
+ if (change && (!bar || change === 'deleted') && highlight?.bar === undefined) {
407
537
  const mark = document.createElement('span');
408
538
  mark.className = `se-change se-change-${change}`;
409
539
  if (change !== 'deleted') {
@@ -413,7 +543,8 @@ export function paintGutter(container: HTMLElement, layout: EditorLayout, rows:
413
543
  }
414
544
  const collapsed = paint.foldable.get(row.line);
415
545
  if (collapsed !== undefined) {
416
- const button = gutterButton(document, collapsed ? 'se-fold-toggle se-folded' : 'se-fold-toggle', collapsed ? 'Expand' : 'Collapse');
546
+ const button = gutterButton(document, collapsed ? 'se-fold-toggle se-folded' : 'se-fold-toggle', paint.foldName(row.line, collapsed));
547
+ button.setAttribute('aria-expanded', String(!collapsed));
417
548
  button.dataset.foldLine = String(row.line);
418
549
  item.append(button);
419
550
  }
@@ -515,6 +646,7 @@ export function paintRemoteCarets(container: HTMLElement, carets: readonly Remot
515
646
 
516
647
  export interface StickyEntry {
517
648
  line: number;
649
+ label: string | null;
518
650
  geometry: LineGeometry;
519
651
  tokens: readonly LineToken[] | null;
520
652
  }
@@ -530,10 +662,10 @@ export function paintSticky(container: HTMLElement, entries: readonly StickyEntr
530
662
  row.dataset.depth = String(depth);
531
663
  const number = document.createElement('span');
532
664
  number.className = 'se-sticky-number';
533
- number.textContent = String(entry.line + 1);
665
+ number.textContent = entry.label;
534
666
  const code = document.createElement('div');
535
667
  code.className = 'se-sticky-code';
536
- code.append(lineElement(document, entry.geometry, entry.tokens, lineHeight, true));
668
+ code.append(lineElement(document, entry.geometry, entry.tokens, lineHeight, { first: 0, last: 0 }));
537
669
  row.append(number, code);
538
670
  return row;
539
671
  })
@@ -567,20 +699,54 @@ export function arrowSign(document: Document, width: number, lineHeight: number,
567
699
  }
568
700
 
569
701
  /* An arrow over each tab of a line. */
570
- function appendTabSigns(element: HTMLElement, geometry: LineGeometry, lineHeight: number, firstRowOnly: boolean): void {
702
+ function appendTabSigns(element: HTMLElement, geometry: LineGeometry, lineHeight: number, band: SubRowBand): void {
703
+ if (geometry.uniform !== undefined) {
704
+ return;
705
+ }
571
706
  const document = element.ownerDocument;
572
- for (let stop = 0; stop < geometry.offsets.length - 1; stop++) {
573
- if (geometry.text[geometry.offsets[stop]!] !== '\t' || (firstRowOnly && geometry.rowOf[stop]! > 0)) {
707
+ const end = geometry.rowStarts[band.last + 1] ?? stopCount(geometry) - 1;
708
+ for (let stop = geometry.rowStarts[band.first]!; stop < end; stop++) {
709
+ if (geometry.text[offsetOfStop(geometry, stop)] !== '\t') {
574
710
  continue;
575
711
  }
576
- const left = geometry.before[stop]!;
577
- const right = geometry.rowOf[stop + 1] === geometry.rowOf[stop] ? geometry.before[stop + 1]! : geometry.rowEnds[geometry.rowOf[stop]!]!;
712
+ const left = xOfStop(geometry, stop);
713
+ const right = rowOfStop(geometry, stop + 1) === rowOfStop(geometry, stop) ? xOfStop(geometry, stop + 1) : geometry.rowEnds[rowOfStop(geometry, stop)]!;
578
714
  if (right - left < 3) {
579
715
  continue;
580
716
  }
581
717
  const sign = arrowSign(document, right - left - 2, lineHeight, 'se-tab-sign');
582
718
  sign.style.left = `${left + 1}px`;
583
- sign.style.top = `${geometry.rowOf[stop]! * lineHeight}px`;
719
+ sign.style.top = `${rowOfStop(geometry, stop) * lineHeight}px`;
720
+ element.append(sign);
721
+ }
722
+ }
723
+
724
+ /*
725
+ * A boxed label over each bidirectional control character, which no run carries, so the control never reaches the text nodes
726
+ * where it would reorder the line. `top` and `originX` place a line that is drawn as part of another one.
727
+ */
728
+ function appendBidiSigns(element: HTMLElement, geometry: LineGeometry, lineHeight: number, band: SubRowBand, originX = 0, top?: number): void {
729
+ if (geometry.uniform !== undefined) {
730
+ return;
731
+ }
732
+ const document = element.ownerDocument;
733
+ const from = offsetOfStop(geometry, geometry.rowStarts[band.first]!);
734
+ const to = geometry.rowStarts[band.last + 1] === undefined ? geometry.text.length : offsetOfStop(geometry, geometry.rowStarts[band.last + 1]!);
735
+ const pattern = new RegExp(BIDI_PATTERN.source, 'g');
736
+ pattern.lastIndex = from;
737
+ for (let match = pattern.exec(geometry.text); match !== null && match.index < to; match = pattern.exec(geometry.text)) {
738
+ const stop = stopAtOffset(geometry, match.index);
739
+ const row = rowOfStop(geometry, stop);
740
+ const control = BIDI_CONTROLS[match[0] as keyof typeof BIDI_CONTROLS];
741
+ const left = xOfStop(geometry, stop);
742
+ const right = rowOfStop(geometry, stop + 1) === row ? xOfStop(geometry, stop + 1) : geometry.rowEnds[row]!;
743
+ const sign = document.createElement('span');
744
+ sign.className = 'se-bidi';
745
+ sign.textContent = control.label;
746
+ sign.style.left = `${originX + left}px`;
747
+ sign.style.top = `${top ?? row * lineHeight}px`;
748
+ sign.style.width = `${right - left}px`;
749
+ sign.style.height = `${lineHeight}px`;
584
750
  element.append(sign);
585
751
  }
586
752
  }