@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.10
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 +499 -313
- package/THIRD_PARTY_NOTICES.md +70 -23
- package/dist/Text-DV9CuzAT.d.ts +452 -0
- package/dist/ansi.d.ts +217 -0
- package/dist/ansi.js +87 -0
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +3 -0
- package/dist/cell-_ZVhbfl0.js +44 -0
- package/dist/color-CkbalRqK.js +2 -0
- package/dist/color-policy-BAC9-TZX.js +592 -0
- package/dist/color-policy-CCxuHIdD.d.ts +22 -0
- package/dist/color-profile-CyeHnG1T.d.ts +97 -0
- package/dist/color-profile-DHhQHY55.js +36 -0
- package/dist/color.d.ts +21 -0
- package/dist/color.js +3 -0
- package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
- package/dist/detect-B3dL4Q11.js +374 -0
- package/dist/detect-Db6GbKOm.d.ts +195 -0
- package/dist/{devtools-QpCMm9JH.mjs → devtools-DbthxoD1.js} +23 -24
- package/dist/env-YVw64yZS.js +9 -0
- package/dist/escapes-CB_6CWOE.d.ts +72 -0
- package/dist/geometry-BxXOzJgo.d.ts +11 -0
- package/dist/index-D48vQhhe.d.ts +21 -0
- package/dist/index-DDVME65c.d.ts +919 -0
- package/dist/index.d.ts +1310 -0
- package/dist/index.js +3209 -0
- package/dist/osc-BFKKSqpg.js +71 -0
- package/dist/osc-Cn0fw77g.d.ts +23 -0
- package/dist/paint-Cx-zC_sX.d.ts +81 -0
- package/dist/query-BNc2B8GD.d.ts +152 -0
- package/dist/router.d.ts +392 -0
- package/dist/router.js +709 -0
- package/dist/sample-Cqw1bjUL.js +445 -0
- package/dist/screen-CgC2WlVM.d.ts +49 -0
- package/dist/screen-CiPytswf.js +342 -0
- package/dist/screen.d.ts +5 -0
- package/dist/screen.js +5 -0
- package/dist/semantic-text-style-DIMzC7xt.js +91 -0
- package/dist/serialize-BTkAZgw1.js +79 -0
- package/dist/session-DDQ5V300.js +723 -0
- package/dist/sgr-BhwaWAJB.js +246 -0
- package/dist/store-C1P5fOUi.d.ts +72 -0
- package/dist/string-width-CijQwpIk.js +69 -0
- package/dist/strip-BvU4toXG.js +6 -0
- package/dist/terminal.d.ts +121 -0
- package/dist/terminal.js +2 -0
- package/dist/tokenize-AjqbvtiT.js +1242 -0
- package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
- package/dist/truncate-D31fhU6i.js +562 -0
- package/dist/use-focus-CcCIdCWA.js +1337 -0
- package/dist/yoga-5jKhYCJC.js +3465 -0
- package/dist/yoga.d.ts +2 -0
- package/dist/yoga.js +2 -0
- package/package.json +70 -24
- package/src/ansi/chalk.ts +138 -0
- package/src/ansi/cursor.ts +46 -0
- package/src/ansi/east-asian-width.ts +259 -0
- package/src/ansi/escapes.ts +120 -0
- package/src/ansi/graphemes.ts +8 -0
- package/src/ansi/hyperlink.ts +44 -0
- package/src/ansi/index.ts +27 -0
- package/src/ansi/osc.ts +77 -0
- package/src/ansi/sgr.ts +234 -0
- package/src/ansi/slice.ts +43 -0
- package/src/ansi/string-width.ts +121 -0
- package/src/ansi/strip.ts +13 -0
- package/src/ansi/tokenize.ts +380 -0
- package/src/ansi/truncate.ts +196 -0
- package/src/ansi/wrap.ts +765 -0
- package/src/ansi-tokenizer.ts +510 -0
- package/src/capabilities/color-policy.ts +34 -0
- package/src/capabilities/detect.ts +608 -0
- package/src/capabilities/index.ts +37 -0
- package/src/capabilities/query.ts +679 -0
- package/src/capabilities/store.ts +394 -0
- package/src/code-excerpt.ts +39 -0
- package/src/color/index.ts +3 -0
- package/src/color/paint.ts +169 -0
- package/src/color/palette.ts +48 -0
- package/src/color/sample.ts +323 -0
- package/src/color.ts +1 -0
- package/src/components/AccessibilityContext.ts +5 -0
- package/src/components/AnimationContext.ts +24 -0
- package/src/components/AnsiText.tsx +42 -0
- package/src/components/App.tsx +878 -0
- package/src/components/AppContext.ts +111 -0
- package/src/components/BackgroundContext.ts +7 -0
- package/src/components/Box.tsx +100 -0
- package/src/components/CursorContext.ts +19 -0
- package/src/components/ErrorBoundary.tsx +39 -0
- package/src/components/ErrorOverview.tsx +135 -0
- package/src/components/FocusContext.ts +30 -0
- package/src/components/Hyperlink.tsx +56 -0
- package/src/components/Newline.tsx +16 -0
- package/src/components/Spacer.tsx +11 -0
- package/src/components/Static.tsx +60 -0
- package/src/components/StderrContext.ts +24 -0
- package/src/components/StdinContext.ts +48 -0
- package/src/components/StdoutContext.ts +26 -0
- package/src/components/TerminalOscContext.ts +25 -0
- package/src/components/Text.tsx +122 -0
- package/src/components/Transform.tsx +38 -0
- package/src/components/VirtualList.tsx +128 -0
- package/src/cursor-position.ts +103 -0
- package/src/devtools.ts +103 -0
- package/src/dom.ts +301 -0
- package/src/env.ts +12 -0
- package/src/get-max-width.ts +11 -0
- package/src/global.d.ts +38 -0
- package/src/glyphs.ts +99 -0
- package/src/hooks/use-animation.ts +142 -0
- package/src/hooks/use-app.ts +8 -0
- package/src/hooks/use-box-metrics.ts +134 -0
- package/src/hooks/use-capabilities.ts +73 -0
- package/src/hooks/use-cursor.ts +33 -0
- package/src/hooks/use-focus-manager.ts +62 -0
- package/src/hooks/use-focus.ts +82 -0
- package/src/hooks/use-input.ts +267 -0
- package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
- package/src/hooks/use-paste.ts +78 -0
- package/src/hooks/use-stderr.ts +8 -0
- package/src/hooks/use-stdin.ts +10 -0
- package/src/hooks/use-stdout.ts +8 -0
- package/src/hooks/use-terminal-osc.ts +59 -0
- package/src/hooks/use-virtual-scroll.ts +84 -0
- package/src/hooks/use-window-size.ts +37 -0
- package/src/index.ts +96 -0
- package/src/ink.tsx +1452 -0
- package/src/input-parser.ts +303 -0
- package/src/instances.ts +9 -0
- package/src/kitty-keyboard.ts +185 -0
- package/src/measure-element.ts +62 -0
- package/src/measure-text.ts +31 -0
- package/src/paint-tree.ts +220 -0
- package/src/parse-keypress.ts +515 -0
- package/src/parse-stack-line.ts +138 -0
- package/src/patch-console.ts +106 -0
- package/src/quick-lru.ts +85 -0
- package/src/reconciler.ts +425 -0
- package/src/render-background.ts +59 -0
- package/src/render-border.ts +167 -0
- package/src/render-frame.ts +83 -0
- package/src/render-to-string.ts +146 -0
- package/src/render.ts +284 -0
- package/src/router/components.tsx +343 -0
- package/src/router/context.ts +41 -0
- package/src/router/history.ts +194 -0
- package/src/router/hooks.tsx +391 -0
- package/src/router/index.ts +34 -0
- package/src/router/matcher.ts +571 -0
- package/src/sanitize-ansi.ts +33 -0
- package/src/screen/ansi.ts +184 -0
- package/src/screen/canvas.ts +160 -0
- package/src/screen/cell.ts +138 -0
- package/src/screen/color-profile.ts +47 -0
- package/src/screen/geometry.ts +9 -0
- package/src/screen/index.ts +6 -0
- package/src/screen/screen.ts +305 -0
- package/src/screen/serialize.ts +129 -0
- package/src/screen.ts +1 -0
- package/src/semantic-text-style.ts +118 -0
- package/src/signal-exit.ts +106 -0
- package/src/squash-text-nodes.ts +37 -0
- package/src/stream.ts +28 -0
- package/src/structured-text.ts +325 -0
- package/src/styles.ts +753 -0
- package/src/terminal/index.ts +2 -0
- package/src/terminal/inline-presenter.ts +120 -0
- package/src/terminal/input.ts +92 -0
- package/src/terminal/render-scheduler.ts +37 -0
- package/src/terminal/screen-presenter.ts +242 -0
- package/src/terminal/session.ts +408 -0
- package/src/terminal-size.ts +58 -0
- package/src/terminal.ts +1 -0
- package/src/testing/browser.ts +588 -0
- package/src/testing/emulators.ts +205 -0
- package/src/testing/explorer-app/index.html +12 -0
- package/src/testing/explorer-app/main.ts +381 -0
- package/src/testing/explorer-app/style.css +194 -0
- package/src/testing/explorer-app/tsconfig.json +15 -0
- package/src/testing/explorer-app/vite-env.d.ts +1 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/keys.ts +56 -0
- package/src/testing/live.ts +85 -0
- package/src/testing/matchers.ts +70 -0
- package/src/testing/public.ts +94 -0
- package/src/testing/terminal.ts +361 -0
- package/src/testing/vitest.ts +157 -0
- package/src/throttle.ts +73 -0
- package/src/transform-adapter.ts +14 -0
- package/src/types.ts +10 -0
- package/src/virtual-scroll.ts +133 -0
- package/src/wrap-text.ts +54 -0
- package/src/yoga/config.ts +57 -0
- package/src/yoga/core/absoluteLayout.ts +626 -0
- package/src/yoga/core/baseline.ts +66 -0
- package/src/yoga/core/cache.ts +136 -0
- package/src/yoga/core/calculateLayout.ts +2926 -0
- package/src/yoga/core/config.ts +104 -0
- package/src/yoga/core/flexLine.ts +177 -0
- package/src/yoga/core/helpers.ts +293 -0
- package/src/yoga/core/layoutResults.ts +167 -0
- package/src/yoga/core/node.ts +611 -0
- package/src/yoga/core/numeric.ts +44 -0
- package/src/yoga/core/pixelGrid.ts +151 -0
- package/src/yoga/core/style.ts +887 -0
- package/src/yoga/core/types.ts +224 -0
- package/src/yoga/generated/YGEnums.ts +263 -0
- package/src/yoga/index.ts +19 -0
- package/src/yoga/node.ts +1140 -0
- package/dist/index.d.mts +0 -2379
- package/dist/index.mjs +0 -10072
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { cliCursor } from "#/ansi/cursor.ts";
|
|
2
|
+
import { ansiEscapes } from "#/ansi/escapes.ts";
|
|
3
|
+
import {
|
|
4
|
+
buildCursorOnlySequence,
|
|
5
|
+
buildCursorSuffix,
|
|
6
|
+
buildReturnToBottomPrefix,
|
|
7
|
+
cursorPositionChanged,
|
|
8
|
+
hideCursorEscape,
|
|
9
|
+
type CursorPosition,
|
|
10
|
+
} from "#/cursor-position.ts";
|
|
11
|
+
|
|
12
|
+
export type InlinePresenter = {
|
|
13
|
+
clear: () => void;
|
|
14
|
+
done: () => void;
|
|
15
|
+
reset: () => void;
|
|
16
|
+
sync: (output: string) => void;
|
|
17
|
+
setCursorPosition: (position: CursorPosition | undefined) => void;
|
|
18
|
+
isCursorDirty: () => boolean;
|
|
19
|
+
willRender: (output: string) => boolean;
|
|
20
|
+
(output: string): boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Full-rewrite fallback and cursor state for the structured inline renderer. */
|
|
24
|
+
export function createInlinePresenter(
|
|
25
|
+
stream: NodeJS.WritableStream,
|
|
26
|
+
options: { readonly showCursor?: boolean } = {},
|
|
27
|
+
): InlinePresenter {
|
|
28
|
+
let previousLineCount = 0;
|
|
29
|
+
let previousOutput = "";
|
|
30
|
+
let hasHiddenCursor = false;
|
|
31
|
+
let cursorPosition: CursorPosition | undefined;
|
|
32
|
+
let cursorDirty = false;
|
|
33
|
+
let previousCursorPosition: CursorPosition | undefined;
|
|
34
|
+
let cursorWasShown = false;
|
|
35
|
+
const showCursor = options.showCursor ?? false;
|
|
36
|
+
|
|
37
|
+
const activeCursor = () => (cursorDirty ? cursorPosition : undefined);
|
|
38
|
+
const hasChanges = (output: string, cursor: CursorPosition | undefined) =>
|
|
39
|
+
output !== previousOutput || cursorPositionChanged(cursor, previousCursorPosition);
|
|
40
|
+
|
|
41
|
+
const present = (output: string): boolean => {
|
|
42
|
+
if (!showCursor && !hasHiddenCursor) {
|
|
43
|
+
cliCursor.hide(stream);
|
|
44
|
+
hasHiddenCursor = true;
|
|
45
|
+
}
|
|
46
|
+
const cursor = activeCursor();
|
|
47
|
+
cursorDirty = false;
|
|
48
|
+
const cursorChanged = cursorPositionChanged(cursor, previousCursorPosition);
|
|
49
|
+
if (!hasChanges(output, cursor)) return false;
|
|
50
|
+
const lines = output.split("\n");
|
|
51
|
+
const suffix = buildCursorSuffix(lines.length - 1, cursor);
|
|
52
|
+
if (output === previousOutput && cursorChanged) {
|
|
53
|
+
stream.write(
|
|
54
|
+
buildCursorOnlySequence({
|
|
55
|
+
cursorWasShown,
|
|
56
|
+
previousLineCount,
|
|
57
|
+
previousCursorPosition,
|
|
58
|
+
cursorPosition: cursor,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
previousOutput = output;
|
|
63
|
+
stream.write(
|
|
64
|
+
buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition) +
|
|
65
|
+
ansiEscapes.eraseLines(previousLineCount) +
|
|
66
|
+
output +
|
|
67
|
+
suffix,
|
|
68
|
+
);
|
|
69
|
+
previousLineCount = lines.length;
|
|
70
|
+
}
|
|
71
|
+
previousCursorPosition = cursor ? { ...cursor } : undefined;
|
|
72
|
+
cursorWasShown = cursor !== undefined;
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
present.clear = () => {
|
|
77
|
+
stream.write(
|
|
78
|
+
buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition) +
|
|
79
|
+
ansiEscapes.eraseLines(previousLineCount),
|
|
80
|
+
);
|
|
81
|
+
previousOutput = "";
|
|
82
|
+
previousLineCount = 0;
|
|
83
|
+
previousCursorPosition = undefined;
|
|
84
|
+
cursorWasShown = false;
|
|
85
|
+
};
|
|
86
|
+
present.done = () => {
|
|
87
|
+
previousOutput = "";
|
|
88
|
+
previousLineCount = 0;
|
|
89
|
+
previousCursorPosition = undefined;
|
|
90
|
+
cursorWasShown = false;
|
|
91
|
+
if (!showCursor) {
|
|
92
|
+
cliCursor.show(stream);
|
|
93
|
+
hasHiddenCursor = false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
present.reset = () => {
|
|
97
|
+
previousOutput = "";
|
|
98
|
+
previousLineCount = 0;
|
|
99
|
+
previousCursorPosition = undefined;
|
|
100
|
+
cursorWasShown = false;
|
|
101
|
+
};
|
|
102
|
+
present.sync = (output: string) => {
|
|
103
|
+
const cursor = activeCursor();
|
|
104
|
+
cursorDirty = false;
|
|
105
|
+
const lines = output.split("\n");
|
|
106
|
+
previousOutput = output;
|
|
107
|
+
previousLineCount = lines.length;
|
|
108
|
+
if (!cursor && cursorWasShown) stream.write(hideCursorEscape);
|
|
109
|
+
if (cursor) stream.write(buildCursorSuffix(lines.length - 1, cursor));
|
|
110
|
+
previousCursorPosition = cursor ? { ...cursor } : undefined;
|
|
111
|
+
cursorWasShown = cursor !== undefined;
|
|
112
|
+
};
|
|
113
|
+
present.setCursorPosition = (position: CursorPosition | undefined) => {
|
|
114
|
+
cursorPosition = position;
|
|
115
|
+
cursorDirty = true;
|
|
116
|
+
};
|
|
117
|
+
present.isCursorDirty = () => cursorDirty;
|
|
118
|
+
present.willRender = (output: string) => hasChanges(output, activeCursor());
|
|
119
|
+
return present;
|
|
120
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isTerminalQueryResponse } from "#/capabilities/query.ts";
|
|
2
|
+
import type { CapabilitiesStore } from "#/capabilities/store.ts";
|
|
3
|
+
import { createInputParser, type InputEvent } from "#/input-parser.ts";
|
|
4
|
+
|
|
5
|
+
export type MouseButton = "left" | "middle" | "right" | "none" | "wheel-up" | "wheel-down";
|
|
6
|
+
export type TerminalMouseEvent = {
|
|
7
|
+
readonly type: "press" | "release" | "move" | "wheel";
|
|
8
|
+
readonly x: number;
|
|
9
|
+
readonly y: number;
|
|
10
|
+
readonly button: MouseButton;
|
|
11
|
+
readonly shift: boolean;
|
|
12
|
+
readonly alt: boolean;
|
|
13
|
+
readonly ctrl: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sgrMouse = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
17
|
+
|
|
18
|
+
/** Stateful stdin decoder that separates terminal reports from application input. */
|
|
19
|
+
export class TerminalInput {
|
|
20
|
+
readonly #parser = createInputParser();
|
|
21
|
+
readonly #capabilities: CapabilitiesStore;
|
|
22
|
+
readonly #mouseListeners = new Set<(event: TerminalMouseEvent) => void>();
|
|
23
|
+
|
|
24
|
+
constructor(capabilities: CapabilitiesStore) {
|
|
25
|
+
this.#capabilities = capabilities;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
push(chunk: string): InputEvent[] {
|
|
29
|
+
return this.#parser.push(chunk).flatMap((event): InputEvent[] => {
|
|
30
|
+
if (typeof event !== "string") return [event];
|
|
31
|
+
// A user Escape immediately before a reply is not an Alt-modified key.
|
|
32
|
+
if (event.startsWith("\u001B\u001B") && isTerminalQueryResponse(event.slice(1))) {
|
|
33
|
+
return ["\u001B"];
|
|
34
|
+
}
|
|
35
|
+
if (this.#capabilities.ingest(event)) return [];
|
|
36
|
+
if (isTerminalQueryResponse(event)) return [];
|
|
37
|
+
const mouse = parseMouseEvent(event);
|
|
38
|
+
if (!mouse) return [event];
|
|
39
|
+
for (const listener of this.#mouseListeners) listener(mouse);
|
|
40
|
+
return [];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
subscribeMouse(listener: (event: TerminalMouseEvent) => void): () => void {
|
|
45
|
+
this.#mouseListeners.add(listener);
|
|
46
|
+
return () => this.#mouseListeners.delete(listener);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
hasPendingEscape(): boolean {
|
|
50
|
+
return this.#parser.hasPendingEscape();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
flushPendingEscape(): string | undefined {
|
|
54
|
+
return this.#parser.flushPendingEscape();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reset(): void {
|
|
58
|
+
this.#parser.reset();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseMouseEvent(sequence: string): TerminalMouseEvent | undefined {
|
|
63
|
+
const match = sequence.match(sgrMouse);
|
|
64
|
+
if (!match) return;
|
|
65
|
+
const code = Number(match[1]);
|
|
66
|
+
const column = Number(match[2]);
|
|
67
|
+
const row = Number(match[3]);
|
|
68
|
+
if (column < 1 || row < 1) return;
|
|
69
|
+
const wheel = (code & 64) !== 0;
|
|
70
|
+
const move = (code & 32) !== 0;
|
|
71
|
+
const buttonCode = code & 3;
|
|
72
|
+
const button: MouseButton = wheel
|
|
73
|
+
? buttonCode === 0
|
|
74
|
+
? "wheel-up"
|
|
75
|
+
: "wheel-down"
|
|
76
|
+
: (["left", "middle", "right", "none"] as const)[buttonCode]!;
|
|
77
|
+
return {
|
|
78
|
+
type: wheel
|
|
79
|
+
? "wheel"
|
|
80
|
+
: move
|
|
81
|
+
? "move"
|
|
82
|
+
: match[4] === "m" || buttonCode === 3
|
|
83
|
+
? "release"
|
|
84
|
+
: "press",
|
|
85
|
+
x: column - 1,
|
|
86
|
+
y: row - 1,
|
|
87
|
+
button,
|
|
88
|
+
shift: (code & 4) !== 0,
|
|
89
|
+
alt: (code & 8) !== 0,
|
|
90
|
+
ctrl: (code & 16) !== 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { throttle, type Throttled } from "#/throttle.ts";
|
|
2
|
+
|
|
3
|
+
export type RenderScheduler = {
|
|
4
|
+
readonly intervalMs: number;
|
|
5
|
+
readonly throttled: Throttled<never[]> | undefined;
|
|
6
|
+
readonly pending: boolean;
|
|
7
|
+
readonly schedule: () => void;
|
|
8
|
+
readonly immediate: () => void;
|
|
9
|
+
markRendered: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Owns frame cadence independently of React reconciliation and terminal presentation. */
|
|
13
|
+
export function createRenderScheduler(
|
|
14
|
+
render: () => void,
|
|
15
|
+
options: { readonly unthrottled: boolean; readonly maxFps: number },
|
|
16
|
+
): RenderScheduler {
|
|
17
|
+
const frameInterval = options.maxFps > 0 ? Math.max(1, Math.ceil(1000 / options.maxFps)) : 0;
|
|
18
|
+
let pending = false;
|
|
19
|
+
const throttled = options.unthrottled ? undefined : throttle(render, frameInterval);
|
|
20
|
+
return {
|
|
21
|
+
intervalMs: options.unthrottled ? 0 : frameInterval,
|
|
22
|
+
throttled,
|
|
23
|
+
get pending() {
|
|
24
|
+
return pending;
|
|
25
|
+
},
|
|
26
|
+
schedule: options.unthrottled
|
|
27
|
+
? render
|
|
28
|
+
: () => {
|
|
29
|
+
pending = true;
|
|
30
|
+
throttled!();
|
|
31
|
+
},
|
|
32
|
+
immediate: render,
|
|
33
|
+
markRendered() {
|
|
34
|
+
pending = false;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { cliCursor } from "#/ansi/cursor.ts";
|
|
2
|
+
import { ansiEscapes } from "#/ansi/escapes.ts";
|
|
3
|
+
import {
|
|
4
|
+
buildCursorOnlySequence,
|
|
5
|
+
buildCursorSuffix,
|
|
6
|
+
buildReturnToBottomPrefix,
|
|
7
|
+
cursorPositionChanged,
|
|
8
|
+
type CursorPosition,
|
|
9
|
+
} from "#/cursor-position.ts";
|
|
10
|
+
import { cellAttributes, cellsEqual, type Cell } from "#/screen/cell.ts";
|
|
11
|
+
import type { ColorProfile } from "#/screen/color-profile.ts";
|
|
12
|
+
import type { Line, Screen } from "#/screen/screen.ts";
|
|
13
|
+
import { serializeLine, serializeScreen } from "#/screen/serialize.ts";
|
|
14
|
+
|
|
15
|
+
type Write = (data: string) => boolean;
|
|
16
|
+
|
|
17
|
+
/** Presents structured frames in an inline terminal region. */
|
|
18
|
+
export class ScreenPresenter {
|
|
19
|
+
#screen: Screen | undefined;
|
|
20
|
+
#lineCount = 0;
|
|
21
|
+
#cursor: CursorPosition | undefined;
|
|
22
|
+
#cursorWasShown = false;
|
|
23
|
+
#fullscreen = false;
|
|
24
|
+
#hidden = false;
|
|
25
|
+
readonly #write: Write;
|
|
26
|
+
readonly #stream: NodeJS.WritableStream;
|
|
27
|
+
|
|
28
|
+
constructor(write: Write, stream: NodeJS.WritableStream) {
|
|
29
|
+
this.#write = write;
|
|
30
|
+
this.#stream = stream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
present(
|
|
34
|
+
screen: Screen,
|
|
35
|
+
options: {
|
|
36
|
+
readonly colorProfile: ColorProfile;
|
|
37
|
+
readonly cursor?: CursorPosition;
|
|
38
|
+
readonly fullscreen?: boolean;
|
|
39
|
+
readonly forceRewrite?: boolean;
|
|
40
|
+
},
|
|
41
|
+
): boolean {
|
|
42
|
+
if (!this.#hidden) {
|
|
43
|
+
cliCursor.hide(this.#stream);
|
|
44
|
+
this.#hidden = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const previousScreen = this.#screen;
|
|
48
|
+
const sameScreen = previousScreen !== undefined && screensEqual(previousScreen, screen);
|
|
49
|
+
const cursorChanged = cursorPositionChanged(options.cursor, this.#cursor);
|
|
50
|
+
const fullscreen = options.fullscreen ?? false;
|
|
51
|
+
const presentationModeChanged = this.#fullscreen !== fullscreen;
|
|
52
|
+
if (!options.forceRewrite && sameScreen && !cursorChanged && !presentationModeChanged) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!options.forceRewrite && sameScreen && !presentationModeChanged) {
|
|
57
|
+
this.#write(
|
|
58
|
+
buildCursorOnlySequence({
|
|
59
|
+
cursorWasShown: this.#cursorWasShown,
|
|
60
|
+
previousLineCount: this.#lineCount,
|
|
61
|
+
previousCursorPosition: this.#cursor,
|
|
62
|
+
cursorPosition: options.cursor,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
this.#remember(screen, options.cursor, options.fullscreen ?? false);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const canRewriteSuffix =
|
|
70
|
+
!options.forceRewrite &&
|
|
71
|
+
previousScreen !== undefined &&
|
|
72
|
+
previousScreen.width === screen.width &&
|
|
73
|
+
this.#fullscreen === fullscreen;
|
|
74
|
+
const firstChangedRow = canRewriteSuffix
|
|
75
|
+
? findFirstChangedRow(previousScreen, screen)
|
|
76
|
+
: undefined;
|
|
77
|
+
const serializeOptions = {
|
|
78
|
+
colorProfile: options.colorProfile,
|
|
79
|
+
styles: options.colorProfile !== "none",
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
if (firstChangedRow !== undefined) {
|
|
83
|
+
const eraseCount = this.#lineCount - firstChangedRow;
|
|
84
|
+
const body = screen
|
|
85
|
+
.toRows()
|
|
86
|
+
.slice(firstChangedRow)
|
|
87
|
+
.map((line) => serializeLine(line, serializeOptions))
|
|
88
|
+
.join("\n");
|
|
89
|
+
const output = fullscreen || body === "" ? body : `${body}\n`;
|
|
90
|
+
this.#write(
|
|
91
|
+
buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
|
|
92
|
+
ansiEscapes.eraseLines(eraseCount) +
|
|
93
|
+
output +
|
|
94
|
+
buildCursorSuffix(screen.height - (fullscreen ? 1 : 0), options.cursor),
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
const body = serializeScreen(screen, serializeOptions);
|
|
98
|
+
const output = fullscreen ? body : `${body}\n`;
|
|
99
|
+
this.#write(
|
|
100
|
+
buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
|
|
101
|
+
ansiEscapes.eraseLines(this.#lineCount) +
|
|
102
|
+
output +
|
|
103
|
+
buildCursorSuffix(screen.height - (fullscreen ? 1 : 0), options.cursor),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#remember(screen, options.cursor, fullscreen);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
willPresent(
|
|
112
|
+
screen: Screen,
|
|
113
|
+
cursor: CursorPosition | undefined,
|
|
114
|
+
fullscreen = false,
|
|
115
|
+
forceRewrite = false,
|
|
116
|
+
): boolean {
|
|
117
|
+
return (
|
|
118
|
+
forceRewrite ||
|
|
119
|
+
this.#screen === undefined ||
|
|
120
|
+
!screensEqual(this.#screen, screen) ||
|
|
121
|
+
cursorPositionChanged(cursor, this.#cursor) ||
|
|
122
|
+
fullscreen !== this.#fullscreen
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
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 {
|
|
137
|
+
this.#write(
|
|
138
|
+
buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
|
|
139
|
+
ansiEscapes.eraseLines(this.#physicalLineCount(options.columns)),
|
|
140
|
+
);
|
|
141
|
+
this.#lineCount = 0;
|
|
142
|
+
this.#cursor = undefined;
|
|
143
|
+
this.#cursorWasShown = false;
|
|
144
|
+
this.#fullscreen = false;
|
|
145
|
+
}
|
|
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
|
+
|
|
163
|
+
reset(): void {
|
|
164
|
+
this.#screen = undefined;
|
|
165
|
+
this.#lineCount = 0;
|
|
166
|
+
this.#cursor = undefined;
|
|
167
|
+
this.#cursorWasShown = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
done(): void {
|
|
171
|
+
this.reset();
|
|
172
|
+
if (this.#hidden) {
|
|
173
|
+
cliCursor.show(this.#stream);
|
|
174
|
+
this.#hidden = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get current(): Screen | undefined {
|
|
179
|
+
return this.#screen;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#remember(screen: Screen, cursor: CursorPosition | undefined, fullscreen: boolean): void {
|
|
183
|
+
this.#screen = screen;
|
|
184
|
+
this.#lineCount = screen.height + (fullscreen ? 0 : 1);
|
|
185
|
+
this.#cursor = cursor ? { ...cursor } : undefined;
|
|
186
|
+
this.#cursorWasShown = cursor !== undefined;
|
|
187
|
+
this.#fullscreen = fullscreen;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
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
|
+
|
|
219
|
+
function findFirstChangedRow(previous: Screen, next: Screen): number | undefined {
|
|
220
|
+
const height = Math.max(previous.height, next.height);
|
|
221
|
+
for (let y = 0; y < height; y++) {
|
|
222
|
+
if (!rowsEqual(previous, next, y)) return y;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function rowsEqual(left: Screen, right: Screen, y: number): boolean {
|
|
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++) {
|
|
231
|
+
if (!cellsEqual(left.cellAt(x, y), right.cellAt(x, y))) return false;
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function screensEqual(left: Screen, right: Screen): boolean {
|
|
237
|
+
if (left.width !== right.width || left.height !== right.height) return false;
|
|
238
|
+
for (let y = 0; y < left.height; y++) {
|
|
239
|
+
if (!rowsEqual(left, right, y)) return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|