@alchemy.run/sigil 0.0.0-alpha.5 → 0.0.0-alpha.7

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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/{Text-D5HUf3Fj.d.ts → Text-DV9CuzAT.d.ts} +3 -3
  3. package/dist/ansi.d.ts +1 -1
  4. package/dist/ansi.js +2 -2
  5. package/dist/capabilities.d.ts +4 -4
  6. package/dist/capabilities.js +2 -2
  7. package/dist/{color-policy-BMzMwV7Q.d.ts → color-policy-CCxuHIdD.d.ts} +2 -2
  8. package/dist/{color-policy-SVj1pYTA.js → color-policy-DlrZXC0f.js} +31 -13
  9. package/dist/{color-profile-u0Nhe9Nv.d.ts → color-profile-CyeHnG1T.d.ts} +1 -1
  10. package/dist/color.d.ts +2 -2
  11. package/dist/{detect-BuTXtY6e.js → detect-B3dL4Q11.js} +3 -2
  12. package/dist/{detect-Bh4yGP6w.d.ts → detect-Db6GbKOm.d.ts} +9 -0
  13. package/dist/{index-Bmc2tRPk.d.ts → index-D48vQhhe.d.ts} +2 -2
  14. package/dist/index.d.ts +10 -6
  15. package/dist/index.js +85 -72
  16. package/dist/{osc-CCH7xDoS.js → osc-BFKKSqpg.js} +1 -1
  17. package/dist/{paint-C19minOS.d.ts → paint-Cx-zC_sX.d.ts} +2 -2
  18. package/dist/{query-vaIeGOkH.d.ts → query-BNc2B8GD.d.ts} +1 -1
  19. package/dist/router.d.ts +1 -1
  20. package/dist/router.js +1 -1
  21. package/dist/{screen-BReKIheE.d.ts → screen-CgC2WlVM.d.ts} +12 -3
  22. package/dist/{screen-BOh__-65.js → screen-CiPytswf.js} +30 -12
  23. package/dist/screen.d.ts +3 -3
  24. package/dist/screen.js +1 -1
  25. package/dist/{session-BJmzX2NJ.js → session-Cg6STjFV.js} +45 -9
  26. package/dist/{store-CgrG9K4y.d.ts → store-C1P5fOUi.d.ts} +1 -1
  27. package/dist/terminal.d.ts +8 -5
  28. package/dist/terminal.js +1 -1
  29. package/dist/{use-focus-C2sciZOo.js → use-focus-DSV01Ulb.js} +3 -1
  30. package/package.json +1 -1
  31. package/src/capabilities/detect.ts +15 -1
  32. package/src/capabilities/store.ts +17 -2
  33. package/src/components/Text.tsx +1 -1
  34. package/src/dom.ts +6 -0
  35. package/src/hooks/use-window-size.ts +8 -19
  36. package/src/ink.tsx +93 -23
  37. package/src/paint-tree.ts +7 -3
  38. package/src/screen/canvas.ts +49 -26
  39. package/src/screen/screen.ts +45 -12
  40. package/src/structured-text.ts +4 -0
  41. package/src/styles.ts +7 -1
  42. package/src/terminal/screen-presenter.ts +62 -8
  43. package/src/terminal/session.ts +3 -2
  44. package/src/testing/terminal.ts +15 -3
  45. package/src/wrap-text.ts +4 -0
  46. package/src/utils.ts +0 -40
package/src/ink.tsx CHANGED
@@ -33,7 +33,6 @@ import { createInlinePresenter } from "#/terminal/inline-presenter.ts";
33
33
  import { createRenderScheduler } from "#/terminal/render-scheduler.ts";
34
34
  import { TerminalSession } from "#/terminal/session.ts";
35
35
  import { type Throttled } from "#/throttle.ts";
36
- import { getWindowSize } from "#/utils.ts";
37
36
  import { Yoga } from "#/yoga/index.ts";
38
37
 
39
38
  const noop = () => {};
@@ -64,6 +63,11 @@ function bottomRows(screen: Screen, height: number): Screen {
64
63
  return cropped;
65
64
  }
66
65
 
66
+ // How long the reported terminal size must hold still before a resize is
67
+ // acted on. Window drags deliver an event per frame (~16 ms), and the
68
+ // emulator needs a frame or so to agree with the PTY size either way.
69
+ const RESIZE_SETTLE_MS = 50;
70
+
67
71
  const shouldClearTerminalForFrame = ({
68
72
  isTTY,
69
73
  viewportRows,
@@ -381,8 +385,20 @@ export const createInk = (options: Options): Ink => {
381
385
  let lastOutputToRender = "";
382
386
  let lastOutputHeight = 0;
383
387
  let lastScreen: Screen | undefined;
384
- let lastTerminalWidth = getWindowSize(options.stdout).columns;
385
- let lastTerminalHeight = getWindowSize(options.stdout).rows;
388
+ // The terminal size comes from the capabilities store: the stream's
389
+ // `columns`/`rows`, or — on terminals that send in-band size reports —
390
+ // the emulator's own figure, which is the one later output will meet.
391
+ const windowSize = () => capabilitiesStore.current.size;
392
+ let lastTerminalWidth = windowSize().columns;
393
+ let lastTerminalHeight = windowSize().rows;
394
+ // Resize events arrive in bursts (one per frame of a window drag), and the
395
+ // emulator's rewrap and the PTY size never update atomically — depending on
396
+ // the terminal app the PTY is resized before or after the screen is
397
+ // rewrapped. Anything written mid-burst lands on a screen whose width is
398
+ // not the reported one, and the rows that leaves behind can never be
399
+ // accounted for by a later erase. Events therefore only arm this settle
400
+ // timer; the frame is erased and repainted once the size has held still.
401
+ let resizeSettle: ReturnType<typeof setTimeout> | undefined;
386
402
 
387
403
  // This variable is used only in debug mode to store full static output
388
404
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
@@ -431,11 +447,17 @@ export const createInk = (options: Options): Ink => {
431
447
  if (options.patchConsole) installConsolePatch();
432
448
 
433
449
  if (interactive) {
434
- options.stdout.on("resize", resized);
435
-
436
- unsubscribeResize = () => {
437
- options.stdout.off("resize", resized);
438
- };
450
+ // Follow the store rather than the stream's `resize` event: the store
451
+ // folds in the terminal's in-band size reports where available, and
452
+ // says which source a size came from.
453
+ let seenColumns = lastTerminalWidth;
454
+ let seenRows = lastTerminalHeight;
455
+ unsubscribeResize = capabilitiesStore.subscribe(({ size }) => {
456
+ if (size.columns === seenColumns && size.rows === seenRows) return;
457
+ seenColumns = size.columns;
458
+ seenRows = size.rows;
459
+ resized(size.source);
460
+ });
439
461
  }
440
462
 
441
463
  initKittyKeyboard();
@@ -450,9 +472,28 @@ export const createInk = (options: Options): Ink => {
450
472
 
451
473
  void exitPromise.catch(noop);
452
474
 
453
- function resized(): void {
454
- const currentWidth = getWindowSize(options.stdout).columns;
455
- const currentHeight = getWindowSize(options.stdout).rows;
475
+ function resized(source: "pty" | "terminal"): void {
476
+ if (resizeSettle !== undefined) {
477
+ clearTimeout(resizeSettle);
478
+ resizeSettle = undefined;
479
+ }
480
+
481
+ // An in-band report is the emulator's own word, sent after it rewrapped
482
+ // and ordered with everything else in the stream, so there is nothing
483
+ // to wait for. A PTY size may run ahead of or behind the emulator.
484
+ if (source === "terminal") {
485
+ settleResize();
486
+ return;
487
+ }
488
+
489
+ resizeSettle = setTimeout(settleResize, RESIZE_SETTLE_MS);
490
+ }
491
+
492
+ function settleResize(): void {
493
+ resizeSettle = undefined;
494
+ if (isUnmounted || isUnmounting) return;
495
+
496
+ const { columns: currentWidth, rows: currentHeight } = windowSize();
456
497
 
457
498
  // A width decrease rewraps lines and any height change moves content
458
499
  // through scrollback, so the incremental render state no longer
@@ -460,11 +501,15 @@ export const createInk = (options: Options): Ink => {
460
501
  // render to be a full rewrite instead of an incremental diff that
461
502
  // would skip "unchanged" lines over stale screen content.
462
503
  if (currentWidth < lastTerminalWidth || currentHeight !== lastTerminalHeight) {
463
- // Clearing erases the full previous frame line count from
464
- // the cursor upward — after a height grow that also covers frame
465
- // lines the emulator pulled back from scrollback, so no extra
466
- // erase is needed for them.
467
- clearLiveOutput();
504
+ // Clearing erases the full previous frame from the cursor upward —
505
+ // after a height grow that also covers frame lines the emulator
506
+ // pulled back from scrollback, so no extra erase is needed for them.
507
+ // The current width lets the presenter count the rows a reflowing
508
+ // emulator has already rewrapped after a width shrink; the logical
509
+ // line count alone under-erases and leaves the frame's top rows
510
+ // behind. Terminals that never rewrap keep the logical count, since
511
+ // the rewrap-aware one would erase rows above the frame there.
512
+ clearLiveOutput(rewrapsOnResize() ? currentWidth : undefined);
468
513
  resetLiveOutput();
469
514
  lastOutput = "";
470
515
  lastOutputToRender = "";
@@ -475,12 +520,20 @@ export const createInk = (options: Options): Ink => {
475
520
  lastOutputHeight = 0;
476
521
  }
477
522
 
523
+ lastTerminalWidth = currentWidth;
524
+ lastTerminalHeight = currentHeight;
525
+
478
526
  calculateLayout();
479
527
  dom.emitLayoutListeners(rootNode);
480
528
  onRender();
529
+ }
481
530
 
482
- lastTerminalWidth = currentWidth;
483
- lastTerminalHeight = currentHeight;
531
+ // Whether the terminal rewraps existing screen rows when its width
532
+ // changes. Nearly every modern emulator does (xterm.js, Ghostty, kitty,
533
+ // iTerm2, WezTerm, Alacritty, Windows Terminal, tmux); Apple's Terminal.app
534
+ // and the classic Windows console keep rows as they were written.
535
+ function rewrapsOnResize(): boolean {
536
+ return !isWindows && capabilitiesStore.current.terminal.name !== "apple-terminal";
484
537
  }
485
538
 
486
539
  function handleAppExit(errorOrResult?: unknown): void {
@@ -519,9 +572,9 @@ export const createInk = (options: Options): Ink => {
519
572
  }
520
573
  }
521
574
 
522
- function clearLiveOutput(): void {
575
+ function clearLiveOutput(columns?: number): void {
523
576
  if (isScreenReaderEnabled) accessiblePresenter!.clear();
524
- else terminal.clearFrame();
577
+ else terminal.clearFrame({ columns });
525
578
  }
526
579
 
527
580
  function finishLiveOutput(): void {
@@ -535,7 +588,7 @@ export const createInk = (options: Options): Ink => {
535
588
  }
536
589
 
537
590
  function calculateLayout(): void {
538
- const terminalWidth = getWindowSize(options.stdout).columns;
591
+ const terminalWidth = windowSize().columns;
539
592
 
540
593
  rootNode.yogaNode!.setWidth(terminalWidth);
541
594
 
@@ -566,6 +619,18 @@ export const createInk = (options: Options): Ink => {
566
619
  return;
567
620
  }
568
621
 
622
+ // A resize burst is still settling: the emulator is rewrapping the screen
623
+ // under us, and the frame will be erased and repainted as a whole once it
624
+ // holds still. Writing now would leave rows no later erase can find.
625
+ if (resizeSettle !== undefined && !isUnmounting) {
626
+ if (nextRenderCommit) {
627
+ nextRenderCommit.resolve();
628
+ nextRenderCommit = undefined;
629
+ }
630
+
631
+ return;
632
+ }
633
+
569
634
  if (nextRenderCommit) {
570
635
  nextRenderCommit.resolve();
571
636
  nextRenderCommit = undefined;
@@ -642,7 +707,7 @@ export const createInk = (options: Options): Ink => {
642
707
  return;
643
708
  }
644
709
 
645
- const terminalWidth = getWindowSize(options.stdout).columns;
710
+ const terminalWidth = windowSize().columns;
646
711
 
647
712
  const wrappedOutput = wrapAnsi(output, terminalWidth, {
648
713
  trim: false,
@@ -796,6 +861,11 @@ export const createInk = (options: Options): Ink => {
796
861
 
797
862
  isUnmounting = true;
798
863
 
864
+ if (resizeSettle !== undefined) {
865
+ clearTimeout(resizeSettle);
866
+ resizeSettle = undefined;
867
+ }
868
+
799
869
  unsubscribeBeforeExit?.();
800
870
  unsubscribeBeforeExit = undefined;
801
871
 
@@ -1163,7 +1233,7 @@ export const createInk = (options: Options): Ink => {
1163
1233
 
1164
1234
  // Detect fullscreen: output fills or exceeds terminal height.
1165
1235
  // Only apply when writing to a real TTY — piped output always gets trailing newlines.
1166
- const viewportRows = isTTY ? getWindowSize(options.stdout).rows : 24;
1236
+ const viewportRows = isTTY ? windowSize().rows : 24;
1167
1237
 
1168
1238
  // Clamp the frame to the viewport, keeping its bottom rows. Rows above
1169
1239
  // the top margin cannot be updated or erased in place, and the
package/src/paint-tree.ts CHANGED
@@ -117,10 +117,14 @@ export const paintTree = (
117
117
  const firstChildYoga = node.childNodes[0]?.yogaNode;
118
118
  const paddingX = firstChildYoga?.getComputedLeft() ?? 0;
119
119
  const paddingY = firstChildYoga?.getComputedTop() ?? 0;
120
+ const textWrap = node.style.textWrap ?? "wrap";
121
+ // `none` text may paint past the screen's nominal width; the screen
122
+ // grows the touched rows on demand. Clip rects still apply.
123
+ const overflow = textWrap === "none";
120
124
  const lines = wrapStructuredText(
121
125
  structuredTextLines(node),
122
126
  maxWidth,
123
- node.style.textWrap ?? "wrap",
127
+ textWrap,
124
128
  structuredTextBaseStyle(node),
125
129
  );
126
130
  const paintBounds = {
@@ -147,10 +151,10 @@ export const paintTree = (
147
151
  }),
148
152
  )
149
153
  .join("\n"),
150
- { transformers: textTransformers },
154
+ { transformers: textTransformers, overflow },
151
155
  );
152
156
  } else {
153
- output.writeCells(paintBounds.x, paintBounds.y, sampled);
157
+ output.writeCells(paintBounds.x, paintBounds.y, sampled, { overflow });
154
158
  }
155
159
  }
156
160
 
@@ -34,8 +34,14 @@ export class Canvas {
34
34
  this.#screen = new Screen(this.width, this.height);
35
35
  }
36
36
 
37
- writeCells(x: number, y: number, lines: readonly (readonly Cell[])[]): void {
37
+ writeCells(
38
+ x: number,
39
+ y: number,
40
+ lines: readonly (readonly Cell[])[],
41
+ options?: { overflow?: boolean },
42
+ ): void {
38
43
  const clip = this.#clips.at(-1);
44
+ const overflow = options?.overflow === true;
39
45
  for (const [rowOffset, line] of lines.entries()) {
40
46
  const currentY = y + rowOffset;
41
47
  if (!this.#insideY(currentY, clip)) continue;
@@ -45,7 +51,7 @@ export class Canvas {
45
51
  const clipped =
46
52
  (clip?.x1 !== undefined && currentX < clip.x1) ||
47
53
  (clip?.x2 !== undefined && endX > clip.x2);
48
- if (!clipped) this.#composeNativeCell(currentX, currentY, cell);
54
+ if (!clipped) this.#composeNativeCell(currentX, currentY, cell, overflow);
49
55
  currentX = endX;
50
56
  }
51
57
  }
@@ -55,11 +61,12 @@ export class Canvas {
55
61
  x: number,
56
62
  y: number,
57
63
  text: string,
58
- options: { transformers: AnsiTransformer[] },
64
+ options: { transformers: AnsiTransformer[]; overflow?: boolean },
59
65
  ): void {
60
66
  if (!text) return;
61
67
  let lines = text.split("\n");
62
68
  const clip = this.#clips.at(-1);
69
+ const overflow = options.overflow === true;
63
70
  if (clip?.y1 !== undefined && y < clip.y1) {
64
71
  lines = lines.slice(clip.y1 - y);
65
72
  y = clip.y1;
@@ -78,7 +85,7 @@ export class Canvas {
78
85
  for (const character of transformAnsiLine(line, lineIndex, options.transformers)) {
79
86
  const width = Math.max(1, stringWidth(character.value));
80
87
  if (clip?.x2 !== undefined && currentX + width > clip.x2) break;
81
- this.#writeCompatibilityCell(currentX, currentY, character, width);
88
+ this.#writeCompatibilityCell(currentX, currentY, character, width, overflow);
82
89
  currentX += width;
83
90
  }
84
91
  }
@@ -104,34 +111,50 @@ export class Canvas {
104
111
  );
105
112
  }
106
113
 
107
- #composeNativeCell(x: number, y: number, cell: Cell): void {
108
- if (x >= this.#screen.width) {
114
+ #composeNativeCell(x: number, y: number, cell: Cell, overflow = false): void {
115
+ if (!overflow && x >= this.#screen.width) {
109
116
  return;
110
117
  }
111
- this.#screen.composeCell(x, y, {
112
- content: { grapheme: cell.grapheme, width: cell.width },
113
- foreground: cell.reset?.foreground ? null : (cell.style.foreground ?? null),
114
- background: cell.reset?.background ? null : cell.style.background,
115
- underlineColor: cell.style.underlineColor ?? null,
116
- underline: cell.style.underline,
117
- attributes: cell.style.attributes,
118
- hyperlink: cell.hyperlink ?? null,
119
- });
118
+ this.#screen.composeCell(
119
+ x,
120
+ y,
121
+ {
122
+ content: { grapheme: cell.grapheme, width: cell.width },
123
+ foreground: cell.reset?.foreground ? null : (cell.style.foreground ?? null),
124
+ background: cell.reset?.background ? null : cell.style.background,
125
+ underlineColor: cell.style.underlineColor ?? null,
126
+ underline: cell.style.underline,
127
+ attributes: cell.style.attributes,
128
+ hyperlink: cell.hyperlink ?? null,
129
+ },
130
+ { overflow },
131
+ );
120
132
  }
121
133
 
122
- #writeCompatibilityCell(x: number, y: number, character: StyledChar, width: number): void {
123
- if (x >= this.#screen.width) {
134
+ #writeCompatibilityCell(
135
+ x: number,
136
+ y: number,
137
+ character: StyledChar,
138
+ width: number,
139
+ overflow = false,
140
+ ): void {
141
+ if (!overflow && x >= this.#screen.width) {
124
142
  return;
125
143
  }
126
144
  const cell = cellFromStyledChar(character, width);
127
- this.#screen.composeCell(x, y, {
128
- content: { grapheme: cell.grapheme, width: cell.width },
129
- foreground: cell.style.foreground ?? null,
130
- background: cell.style.background,
131
- underlineColor: cell.style.underlineColor ?? null,
132
- underline: cell.style.underline,
133
- attributes: cell.style.attributes,
134
- hyperlink: cell.hyperlink ?? null,
135
- });
145
+ this.#screen.composeCell(
146
+ x,
147
+ y,
148
+ {
149
+ content: { grapheme: cell.grapheme, width: cell.width },
150
+ foreground: cell.style.foreground ?? null,
151
+ background: cell.style.background,
152
+ underlineColor: cell.style.underlineColor ?? null,
153
+ underline: cell.style.underline,
154
+ attributes: cell.style.attributes,
155
+ hyperlink: cell.hyperlink ?? null,
156
+ },
157
+ { overflow },
158
+ );
136
159
  }
137
160
  }
@@ -39,6 +39,14 @@ export class Screen {
39
39
  return this.#width;
40
40
  }
41
41
 
42
+ /**
43
+ The populated length of one row: the nominal width, or more when overflow
44
+ writes (`textWrap: "none"` content) extended it past the screen's width.
45
+ */
46
+ rowLength(y: number): number {
47
+ return Math.max(this.#width, this.#rows[y]?.length ?? 0);
48
+ }
49
+
42
50
  get height(): number {
43
51
  return this.#height;
44
52
  }
@@ -56,9 +64,10 @@ export class Screen {
56
64
  return this.#painted[y]?.[x] ?? false;
57
65
  }
58
66
 
59
- setCell(x: number, y: number, cell: Cell): void {
67
+ setCell(x: number, y: number, cell: Cell, options?: { overflow?: boolean }): void {
60
68
  const row = this.#rows[y];
61
- if (!row || x < 0 || x >= this.width) {
69
+ const overflow = options?.overflow === true;
70
+ if (!row || x < 0 || (!overflow && x >= this.width)) {
62
71
  return;
63
72
  }
64
73
 
@@ -67,18 +76,23 @@ export class Screen {
67
76
  }
68
77
 
69
78
  const finalColumn = x + cell.width;
70
- const clipped = finalColumn > this.width;
71
- const overwriteEnd = Math.min(finalColumn, this.width);
79
+ if (overflow && finalColumn > row.length) {
80
+ this.#growRow(row, y, finalColumn);
81
+ }
82
+
83
+ const capacity = overflow ? row.length : this.width;
84
+ const clipped = finalColumn > capacity;
85
+ const overwriteEnd = Math.min(finalColumn, capacity);
72
86
 
73
87
  for (let column = x; column < overwriteEnd; column++) {
74
88
  this.#clearGraphemeAt(row, y, column);
75
89
  }
76
90
 
77
91
  if (clipped) {
78
- for (let column = x; column < this.width; column++) {
92
+ for (let column = x; column < capacity; column++) {
79
93
  row[column] = emptyCell;
80
94
  }
81
- this.#markDirty(y, x, this.width);
95
+ this.#markDirty(y, x, capacity);
82
96
 
83
97
  return;
84
98
  }
@@ -102,13 +116,18 @@ export class Screen {
102
116
  Composes independent cell channels over the existing cell. An undefined
103
117
  patch is transparent; an explicit space in `content` paints a blank.
104
118
  */
105
- composeCell(x: number, y: number, patch: CellPatch | undefined): void {
119
+ composeCell(
120
+ x: number,
121
+ y: number,
122
+ patch: CellPatch | undefined,
123
+ options?: { overflow?: boolean },
124
+ ): void {
106
125
  if (!patch) {
107
126
  return;
108
127
  }
109
128
 
110
129
  const row = this.#rows[y];
111
- if (!row || x < 0 || x >= this.width) {
130
+ if (!row || x < 0 || (options?.overflow !== true && x >= this.width)) {
112
131
  return;
113
132
  }
114
133
 
@@ -130,7 +149,12 @@ export class Screen {
130
149
  const hyperlink =
131
150
  patch.hyperlink === undefined ? destination.hyperlink : (patch.hyperlink ?? undefined);
132
151
 
133
- this.setCell(writeColumn, y, createCell(content.grapheme, content.width, style, hyperlink));
152
+ this.setCell(
153
+ writeColumn,
154
+ y,
155
+ createCell(content.grapheme, content.width, style, hyperlink),
156
+ options,
157
+ );
134
158
  }
135
159
 
136
160
  /** Composes a patch throughout a rectangle, clipped to this screen. */
@@ -191,7 +215,7 @@ export class Screen {
191
215
  for (const [y, row] of this.#rows.entries()) {
192
216
  row.fill(emptyCell);
193
217
  this.#painted[y]!.fill(true);
194
- this.#markDirty(y, 0, this.width);
218
+ this.#markDirty(y, 0, this.rowLength(y));
195
219
  }
196
220
  }
197
221
 
@@ -209,6 +233,15 @@ export class Screen {
209
233
  return spans;
210
234
  }
211
235
 
236
+ /** Extends a row (and its paint mask) so overflow writes land past `width`. */
237
+ #growRow(row: Cell[], y: number, length: number): void {
238
+ const painted = this.#painted[y]!;
239
+ while (row.length < length) {
240
+ row.push(emptyCell);
241
+ painted.push(false);
242
+ }
243
+ }
244
+
212
245
  #clearGraphemeAt(row: Cell[], y: number, x: number): void {
213
246
  const existing = row[x];
214
247
  if (!existing || existing === emptyCell) {
@@ -224,7 +257,7 @@ export class Screen {
224
257
  const width = Math.max(1, leadingCell?.width ?? 1);
225
258
  for (
226
259
  let column = leadingColumn;
227
- column < Math.min(leadingColumn + width, this.width);
260
+ column < Math.min(leadingColumn + width, row.length);
228
261
  column++
229
262
  ) {
230
263
  row[column] = emptyCell;
@@ -257,7 +290,7 @@ export class Screen {
257
290
  }
258
291
 
259
292
  const clippedStart = Math.max(0, start);
260
- const clippedEnd = Math.min(this.width, end);
293
+ const clippedEnd = Math.min(this.rowLength(y), end);
261
294
  const current = this.#dirty[y];
262
295
  this.#dirty[y] = current
263
296
  ? { start: Math.min(current.start, clippedStart), end: Math.max(current.end, clippedEnd) }
@@ -81,6 +81,10 @@ export function wrapStructuredText(
81
81
  wrap: Styles["textWrap"],
82
82
  baseStyle?: CellStyle,
83
83
  ): readonly (readonly Cell[])[] {
84
+ if (wrap === "none") {
85
+ return input.map((line) => [...line]);
86
+ }
87
+
84
88
  if (maxWidth < 1) {
85
89
  return [[]];
86
90
  }
package/src/styles.ts CHANGED
@@ -5,6 +5,11 @@ import { Yoga, type Node as YogaNode, type PositionType } from "#/yoga/index.ts"
5
5
  export type Styles = {
6
6
  /*
7
7
  We keep this as a single enum so overflow is one complete choice and invalid combinations like wrap + truncate-middle are unrepresentable. In hindsight, `normal` would have been a clearer default value than `wrap`, since it describes the standard behavior instead of repeating the prop name.
8
+
9
+ `none` neither wraps nor truncates: the text overflows the container (and
10
+ the screen's nominal width). Meant for scrollback content (`<Static>`),
11
+ not live regions, where a physically soft-wrapped line breaks the
12
+ presenter's line accounting.
8
13
  */
9
14
  readonly textWrap?:
10
15
  | "wrap"
@@ -12,7 +17,8 @@ export type Styles = {
12
17
  | "truncate-end"
13
18
  | "truncate"
14
19
  | "truncate-middle"
15
- | "truncate-start";
20
+ | "truncate-start"
21
+ | "none";
16
22
 
17
23
  /**
18
24
  Controls how the element is positioned.
@@ -7,9 +7,9 @@ import {
7
7
  cursorPositionChanged,
8
8
  type CursorPosition,
9
9
  } from "#/cursor-position.ts";
10
- import { cellsEqual } from "#/screen/cell.ts";
10
+ import { cellAttributes, cellsEqual, type Cell } from "#/screen/cell.ts";
11
11
  import type { ColorProfile } from "#/screen/color-profile.ts";
12
- import type { Screen } from "#/screen/screen.ts";
12
+ import type { Line, Screen } from "#/screen/screen.ts";
13
13
  import { serializeLine, serializeScreen } from "#/screen/serialize.ts";
14
14
 
15
15
  type Write = (data: string) => boolean;
@@ -123,10 +123,20 @@ export class ScreenPresenter {
123
123
  );
124
124
  }
125
125
 
126
- clear(): void {
126
+ /**
127
+ Erases the presented frame from the cursor upward.
128
+
129
+ `columns` is the terminal's current width. When it is narrower than the width
130
+ the frame was painted at, a reflowing emulator (xterm.js, Ghostty, kitty,
131
+ iTerm2, WezTerm, tmux, …) has already rewrapped every wider row onto several
132
+ physical rows before the resize event reaches us, so the erase has to cover
133
+ that physical footprint. Erasing only the logical row count leaves the frame's
134
+ top rows behind as ghosts, one more batch per resize event.
135
+ */
136
+ clear(options: { readonly columns?: number } = {}): void {
127
137
  this.#write(
128
138
  buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
129
- ansiEscapes.eraseLines(this.#lineCount),
139
+ ansiEscapes.eraseLines(this.#physicalLineCount(options.columns)),
130
140
  );
131
141
  this.#lineCount = 0;
132
142
  this.#cursor = undefined;
@@ -134,6 +144,22 @@ export class ScreenPresenter {
134
144
  this.#fullscreen = false;
135
145
  }
136
146
 
147
+ #physicalLineCount(columns: number | undefined): number {
148
+ if (
149
+ this.#screen === undefined ||
150
+ this.#lineCount === 0 ||
151
+ columns === undefined ||
152
+ columns < 1
153
+ ) {
154
+ return this.#lineCount;
155
+ }
156
+ let rows = 0;
157
+ for (const line of this.#screen.toRows()) {
158
+ rows += Math.max(1, Math.ceil(contentWidth(line) / columns));
159
+ }
160
+ return rows + (this.#fullscreen ? 0 : 1);
161
+ }
162
+
137
163
  reset(): void {
138
164
  this.#screen = undefined;
139
165
  this.#lineCount = 0;
@@ -162,6 +188,34 @@ export class ScreenPresenter {
162
188
  }
163
189
  }
164
190
 
191
+ /**
192
+ The columns a row occupies once written: everything up to its last visible
193
+ cell. Trailing unstyled blanks are trimmed by the serializer and never reach
194
+ the terminal, while styled blanks (a highlighted tab's padding) do.
195
+ */
196
+ function contentWidth(line: Line): number {
197
+ let width = 0;
198
+ let end = 0;
199
+ for (const cell of line) {
200
+ width += cell.width;
201
+ if (!isBlank(cell)) end = width;
202
+ }
203
+ return end;
204
+ }
205
+
206
+ function isBlank(cell: Cell): boolean {
207
+ const { style } = cell;
208
+ return (
209
+ (cell.grapheme === " " || cell.width === 0) &&
210
+ style.foreground === undefined &&
211
+ style.background === undefined &&
212
+ style.underlineColor === undefined &&
213
+ style.underline === "none" &&
214
+ style.attributes === cellAttributes.none &&
215
+ cell.hyperlink === undefined
216
+ );
217
+ }
218
+
165
219
  function findFirstChangedRow(previous: Screen, next: Screen): number | undefined {
166
220
  const height = Math.max(previous.height, next.height);
167
221
  for (let y = 0; y < height; y++) {
@@ -171,7 +225,9 @@ function findFirstChangedRow(previous: Screen, next: Screen): number | undefined
171
225
  }
172
226
 
173
227
  function rowsEqual(left: Screen, right: Screen, y: number): boolean {
174
- for (let x = 0; x < left.width; x++) {
228
+ // Rows can extend past the nominal width when overflow content painted.
229
+ const length = Math.max(left.rowLength(y), right.rowLength(y));
230
+ for (let x = 0; x < length; x++) {
175
231
  if (!cellsEqual(left.cellAt(x, y), right.cellAt(x, y))) return false;
176
232
  }
177
233
  return true;
@@ -180,9 +236,7 @@ function rowsEqual(left: Screen, right: Screen, y: number): boolean {
180
236
  function screensEqual(left: Screen, right: Screen): boolean {
181
237
  if (left.width !== right.width || left.height !== right.height) return false;
182
238
  for (let y = 0; y < left.height; y++) {
183
- for (let x = 0; x < left.width; x++) {
184
- if (!cellsEqual(left.cellAt(x, y), right.cellAt(x, y))) return false;
185
- }
239
+ if (!rowsEqual(left, right, y)) return false;
186
240
  }
187
241
  return true;
188
242
  }
@@ -140,8 +140,9 @@ export class TerminalSession {
140
140
  );
141
141
  }
142
142
 
143
- clearFrame(): void {
144
- this.#presenter.clear();
143
+ /** Erases the presented frame; pass the current `columns` after a resize so the erase covers rows the emulator rewrapped. */
144
+ clearFrame(options: { readonly columns?: number } = {}): void {
145
+ this.#presenter.clear(options);
145
146
  }
146
147
 
147
148
  resetFrame(): void {