@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.
- package/README.md +1 -1
- package/dist/{Text-D5HUf3Fj.d.ts → Text-DV9CuzAT.d.ts} +3 -3
- package/dist/ansi.d.ts +1 -1
- package/dist/ansi.js +2 -2
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +2 -2
- package/dist/{color-policy-BMzMwV7Q.d.ts → color-policy-CCxuHIdD.d.ts} +2 -2
- package/dist/{color-policy-SVj1pYTA.js → color-policy-DlrZXC0f.js} +31 -13
- package/dist/{color-profile-u0Nhe9Nv.d.ts → color-profile-CyeHnG1T.d.ts} +1 -1
- package/dist/color.d.ts +2 -2
- package/dist/{detect-BuTXtY6e.js → detect-B3dL4Q11.js} +3 -2
- package/dist/{detect-Bh4yGP6w.d.ts → detect-Db6GbKOm.d.ts} +9 -0
- package/dist/{index-Bmc2tRPk.d.ts → index-D48vQhhe.d.ts} +2 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +85 -72
- package/dist/{osc-CCH7xDoS.js → osc-BFKKSqpg.js} +1 -1
- package/dist/{paint-C19minOS.d.ts → paint-Cx-zC_sX.d.ts} +2 -2
- package/dist/{query-vaIeGOkH.d.ts → query-BNc2B8GD.d.ts} +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +1 -1
- package/dist/{screen-BReKIheE.d.ts → screen-CgC2WlVM.d.ts} +12 -3
- package/dist/{screen-BOh__-65.js → screen-CiPytswf.js} +30 -12
- package/dist/screen.d.ts +3 -3
- package/dist/screen.js +1 -1
- package/dist/{session-BJmzX2NJ.js → session-Cg6STjFV.js} +45 -9
- package/dist/{store-CgrG9K4y.d.ts → store-C1P5fOUi.d.ts} +1 -1
- package/dist/terminal.d.ts +8 -5
- package/dist/terminal.js +1 -1
- package/dist/{use-focus-C2sciZOo.js → use-focus-DSV01Ulb.js} +3 -1
- package/package.json +1 -1
- package/src/capabilities/detect.ts +15 -1
- package/src/capabilities/store.ts +17 -2
- package/src/components/Text.tsx +1 -1
- package/src/dom.ts +6 -0
- package/src/hooks/use-window-size.ts +8 -19
- package/src/ink.tsx +93 -23
- package/src/paint-tree.ts +7 -3
- package/src/screen/canvas.ts +49 -26
- package/src/screen/screen.ts +45 -12
- package/src/structured-text.ts +4 -0
- package/src/styles.ts +7 -1
- package/src/terminal/screen-presenter.ts +62 -8
- package/src/terminal/session.ts +3 -2
- package/src/testing/terminal.ts +15 -3
- package/src/wrap-text.ts +4 -0
- 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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
package/src/screen/canvas.ts
CHANGED
|
@@ -34,8 +34,14 @@ export class Canvas {
|
|
|
34
34
|
this.#screen = new Screen(this.width, this.height);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
writeCells(
|
|
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(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
123
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
}
|
package/src/screen/screen.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 <
|
|
92
|
+
for (let column = x; column < capacity; column++) {
|
|
79
93
|
row[column] = emptyCell;
|
|
80
94
|
}
|
|
81
|
-
this.#markDirty(y, x,
|
|
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(
|
|
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(
|
|
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.
|
|
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,
|
|
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.
|
|
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) }
|
package/src/structured-text.ts
CHANGED
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/terminal/session.ts
CHANGED
|
@@ -140,8 +140,9 @@ export class TerminalSession {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|