@svelterm/core 0.1.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.
- package/CHANGELOG.md +465 -0
- package/README.md +42 -29
- package/dist/src/cli/build.d.ts +13 -0
- package/dist/src/cli/build.js +119 -0
- package/dist/src/cli/bundle.d.ts +25 -0
- package/dist/src/cli/bundle.js +61 -0
- package/dist/src/cli/dev.d.ts +10 -0
- package/dist/src/cli/dev.js +152 -0
- package/dist/src/cli/devtools.d.ts +9 -0
- package/dist/src/cli/devtools.js +47 -0
- package/dist/src/cli/init.d.ts +8 -0
- package/dist/src/cli/init.js +153 -0
- package/dist/src/cli/main.d.ts +9 -0
- package/dist/src/cli/main.js +52 -0
- package/dist/src/cli/svt-bin.d.ts +2 -0
- package/dist/src/cli/svt-bin.js +6 -0
- package/dist/src/cli/svt.d.ts +14 -0
- package/dist/src/cli/svt.js +76 -0
- package/dist/src/components/text-buffer.js +8 -5
- package/dist/src/css/animation-runner.d.ts +15 -6
- package/dist/src/css/animation-runner.js +80 -29
- package/dist/src/css/animation.d.ts +12 -0
- package/dist/src/css/animation.js +21 -0
- package/dist/src/css/calc.js +4 -3
- package/dist/src/css/color.d.ts +19 -0
- package/dist/src/css/color.js +371 -62
- package/dist/src/css/compute.d.ts +31 -4
- package/dist/src/css/compute.js +273 -34
- package/dist/src/css/defaults.d.ts +1 -1
- package/dist/src/css/defaults.js +9 -0
- package/dist/src/css/easing.d.ts +9 -0
- package/dist/src/css/easing.js +95 -0
- package/dist/src/css/incremental.d.ts +1 -1
- package/dist/src/css/incremental.js +2 -2
- package/dist/src/css/interpolate.d.ts +13 -0
- package/dist/src/css/interpolate.js +41 -0
- package/dist/src/css/parser.js +59 -3
- package/dist/src/css/pseudo-elements.d.ts +9 -0
- package/dist/src/css/pseudo-elements.js +97 -0
- package/dist/src/css/selector.d.ts +17 -2
- package/dist/src/css/selector.js +128 -13
- package/dist/src/css/specificity.js +17 -6
- package/dist/src/css/values.d.ts +6 -1
- package/dist/src/css/values.js +13 -6
- package/dist/src/debug/context.d.ts +13 -0
- package/dist/src/debug/context.js +11 -0
- package/dist/src/debug/css.d.ts +12 -0
- package/dist/src/debug/css.js +28 -0
- package/dist/src/debug/dom.d.ts +17 -0
- package/dist/src/debug/dom.js +92 -0
- package/dist/src/devtools/DevTools.compiled.js +327 -0
- package/dist/src/devtools/DevTools.css.js +1 -0
- package/dist/src/devtools/client.d.ts +36 -0
- package/dist/src/devtools/client.js +76 -0
- package/dist/src/framelog.d.ts +54 -0
- package/dist/src/framelog.js +99 -0
- package/dist/src/headless.js +12 -4
- package/dist/src/index.d.ts +66 -3
- package/dist/src/index.js +610 -81
- package/dist/src/input/checkable.d.ts +8 -0
- package/dist/src/input/checkable.js +66 -0
- package/dist/src/input/details.d.ts +6 -0
- package/dist/src/input/details.js +34 -0
- package/dist/src/input/focus.d.ts +6 -0
- package/dist/src/input/focus.js +27 -9
- package/dist/src/input/keyboard.d.ts +2 -2
- package/dist/src/input/keyboard.js +32 -5
- package/dist/src/input/label.d.ts +8 -0
- package/dist/src/input/label.js +53 -0
- package/dist/src/input/modal.d.ts +9 -0
- package/dist/src/input/modal.js +28 -0
- package/dist/src/input/mouse.d.ts +2 -2
- package/dist/src/input/mouse.js +15 -2
- package/dist/src/input/select.d.ts +12 -0
- package/dist/src/input/select.js +63 -0
- package/dist/src/input/selection.d.ts +48 -0
- package/dist/src/input/selection.js +150 -0
- package/dist/src/layout/engine.d.ts +2 -0
- package/dist/src/layout/engine.js +1092 -142
- package/dist/src/layout/flex.js +4 -4
- package/dist/src/layout/size.js +3 -2
- package/dist/src/layout/text.d.ts +3 -2
- package/dist/src/layout/text.js +96 -17
- package/dist/src/layout/unicode.d.ts +20 -0
- package/dist/src/layout/unicode.js +121 -0
- package/dist/src/render/animation-clock.d.ts +57 -0
- package/dist/src/render/animation-clock.js +221 -0
- package/dist/src/render/ansi-text.d.ts +26 -0
- package/dist/src/render/ansi-text.js +131 -0
- package/dist/src/render/ansi.d.ts +18 -0
- package/dist/src/render/ansi.js +64 -19
- package/dist/src/render/border.js +166 -17
- package/dist/src/render/buffer.d.ts +1 -0
- package/dist/src/render/buffer.js +5 -2
- package/dist/src/render/clock.d.ts +35 -0
- package/dist/src/render/clock.js +67 -0
- package/dist/src/render/color-depth.d.ts +8 -0
- package/dist/src/render/color-depth.js +59 -0
- package/dist/src/render/context.d.ts +1 -0
- package/dist/src/render/context.js +17 -21
- package/dist/src/render/cursor-emit.d.ts +18 -0
- package/dist/src/render/cursor-emit.js +50 -0
- package/dist/src/render/diff.d.ts +12 -0
- package/dist/src/render/diff.js +120 -0
- package/dist/src/render/generation.d.ts +9 -0
- package/dist/src/render/generation.js +14 -0
- package/dist/src/render/graphics-layer.d.ts +27 -0
- package/dist/src/render/graphics-layer.js +86 -0
- package/dist/src/render/image.d.ts +27 -0
- package/dist/src/render/image.js +113 -0
- package/dist/src/render/incremental-paint.d.ts +7 -3
- package/dist/src/render/incremental-paint.js +52 -79
- package/dist/src/render/inline.d.ts +59 -0
- package/dist/src/render/inline.js +219 -0
- package/dist/src/render/kitty-graphics.d.ts +24 -0
- package/dist/src/render/kitty-graphics.js +58 -0
- package/dist/src/render/paint-text.js +68 -22
- package/dist/src/render/paint.d.ts +8 -1
- package/dist/src/render/paint.js +358 -31
- package/dist/src/render/png.d.ts +13 -0
- package/dist/src/render/png.js +145 -0
- package/dist/src/render/scrollbar.d.ts +8 -2
- package/dist/src/render/scrollbar.js +71 -14
- package/dist/src/render/snapshot.js +3 -1
- package/dist/src/renderer/default.d.ts +7 -0
- package/dist/src/renderer/default.js +11 -0
- package/dist/src/renderer/index.d.ts +8 -2
- package/dist/src/renderer/index.js +4 -2
- package/dist/src/renderer/node.d.ts +109 -0
- package/dist/src/renderer/node.js +165 -1
- package/dist/src/terminal/capabilities.d.ts +33 -0
- package/dist/src/terminal/capabilities.js +66 -0
- package/dist/src/terminal/clipboard.d.ts +9 -0
- package/dist/src/terminal/clipboard.js +39 -0
- package/dist/src/terminal/io.d.ts +82 -0
- package/dist/src/terminal/io.js +155 -0
- package/dist/src/terminal/screen.d.ts +3 -10
- package/dist/src/terminal/screen.js +5 -28
- package/dist/src/terminal/stdin-router.d.ts +8 -5
- package/dist/src/terminal/stdin-router.js +22 -11
- package/dist/src/utils/node-map.d.ts +24 -0
- package/dist/src/utils/node-map.js +75 -0
- package/dist/src/vite/config.d.ts +62 -0
- package/dist/src/vite/config.js +191 -0
- package/docs/compatibility.md +67 -0
- package/docs/debug/devtools.md +40 -0
- package/docs/debug/svt.md +50 -0
- package/docs/distribution.md +106 -0
- package/docs/elements.md +120 -0
- package/docs/getting-started.md +177 -0
- package/docs/guide/css.md +187 -0
- package/docs/guide/input.md +143 -0
- package/docs/guide/layout.md +171 -0
- package/docs/guide/theming.md +94 -0
- package/docs/how-it-works.md +115 -0
- package/docs/inline-mode.md +77 -0
- package/docs/layout.md +112 -0
- package/docs/motion.md +91 -0
- package/docs/reference/README.md +65 -0
- package/docs/reference/css/properties/border-corner.md +82 -0
- package/docs/reference/css/properties/border-style.md +168 -0
- package/docs/reference.md +227 -0
- package/docs/selectors.md +80 -0
- package/docs/terminal-css.md +149 -0
- package/docs/terminals.md +83 -0
- package/package.json +28 -7
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour quantization for terminals without truecolor: hex → the xterm
|
|
3
|
+
* 256-colour palette, or the nearest of the 16 base colours.
|
|
4
|
+
*/
|
|
5
|
+
/** Nominal xterm RGB values for the 16 SGR names. */
|
|
6
|
+
const BASE16 = [
|
|
7
|
+
{ name: 'black', rgb: [0, 0, 0] },
|
|
8
|
+
{ name: 'red', rgb: [205, 0, 0] },
|
|
9
|
+
{ name: 'green', rgb: [0, 205, 0] },
|
|
10
|
+
{ name: 'yellow', rgb: [205, 205, 0] },
|
|
11
|
+
{ name: 'blue', rgb: [0, 0, 238] },
|
|
12
|
+
{ name: 'magenta', rgb: [205, 0, 205] },
|
|
13
|
+
{ name: 'cyan', rgb: [0, 205, 205] },
|
|
14
|
+
{ name: 'white', rgb: [229, 229, 229] },
|
|
15
|
+
];
|
|
16
|
+
function hexToRgb(hex) {
|
|
17
|
+
return [
|
|
18
|
+
parseInt(hex.slice(1, 3), 16),
|
|
19
|
+
parseInt(hex.slice(3, 5), 16),
|
|
20
|
+
parseInt(hex.slice(5, 7), 16),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
/** The 0–5 colour-cube step nearest a channel value, per xterm's levels. */
|
|
24
|
+
const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
|
|
25
|
+
function nearestCubeStep(value) {
|
|
26
|
+
let best = 0;
|
|
27
|
+
for (let i = 1; i < CUBE_LEVELS.length; i++) {
|
|
28
|
+
if (Math.abs(CUBE_LEVELS[i] - value) < Math.abs(CUBE_LEVELS[best] - value))
|
|
29
|
+
best = i;
|
|
30
|
+
}
|
|
31
|
+
return best;
|
|
32
|
+
}
|
|
33
|
+
function distance(a, b) {
|
|
34
|
+
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
35
|
+
}
|
|
36
|
+
/** Map #rrggbb to the nearest xterm 256-palette index (cube or grey ramp). */
|
|
37
|
+
export function quantizeTo256(hex) {
|
|
38
|
+
const rgb = hexToRgb(hex);
|
|
39
|
+
const [r, g, b] = rgb;
|
|
40
|
+
const steps = [nearestCubeStep(r), nearestCubeStep(g), nearestCubeStep(b)];
|
|
41
|
+
const cubeIndex = 16 + 36 * steps[0] + 6 * steps[1] + steps[2];
|
|
42
|
+
const cubeRgb = [CUBE_LEVELS[steps[0]], CUBE_LEVELS[steps[1]], CUBE_LEVELS[steps[2]]];
|
|
43
|
+
// Grey ramp: 232–255 covering 8..238 in steps of 10
|
|
44
|
+
const grey = Math.round((r + g + b) / 3);
|
|
45
|
+
const greyStep = Math.min(23, Math.max(0, Math.round((grey - 8) / 10)));
|
|
46
|
+
const greyValue = 8 + greyStep * 10;
|
|
47
|
+
const greyRgb = [greyValue, greyValue, greyValue];
|
|
48
|
+
return distance(rgb, greyRgb) < distance(rgb, cubeRgb) ? 232 + greyStep : cubeIndex;
|
|
49
|
+
}
|
|
50
|
+
/** Map #rrggbb to the nearest of the 16 base colour names. */
|
|
51
|
+
export function quantizeTo16(hex) {
|
|
52
|
+
const rgb = hexToRgb(hex);
|
|
53
|
+
let best = BASE16[0];
|
|
54
|
+
for (const candidate of BASE16) {
|
|
55
|
+
if (distance(rgb, candidate.rgb) < distance(rgb, best.rgb))
|
|
56
|
+
best = candidate;
|
|
57
|
+
}
|
|
58
|
+
return best.name;
|
|
59
|
+
}
|
|
@@ -11,6 +11,7 @@ export declare class RenderContext {
|
|
|
11
11
|
onSetText(node: TermNode, newText: string): void;
|
|
12
12
|
onSetAttribute(node: TermNode, key: string, value: string): void;
|
|
13
13
|
onRemoveAttribute(node: TermNode, key: string): void;
|
|
14
|
+
private invalidateStyles;
|
|
14
15
|
onInsert(parent: TermNode, child: TermNode): void;
|
|
15
16
|
onRemove(child: TermNode, parent: TermNode): void;
|
|
16
17
|
onScroll(node: TermNode): void;
|
|
@@ -19,35 +19,31 @@ export class RenderContext {
|
|
|
19
19
|
this.onScheduleRender?.();
|
|
20
20
|
}
|
|
21
21
|
onSetAttribute(node, key, value) {
|
|
22
|
-
if (key ===
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
if (node.attributes.get(key) === value)
|
|
23
|
+
return; // no change
|
|
24
|
+
// Any attribute can participate in selector matching ([attr=…],
|
|
25
|
+
// :checked, details[open] > …, inline style), so re-resolve the
|
|
26
|
+
// node and its descendants.
|
|
27
|
+
if (key === 'class')
|
|
25
28
|
node.cache.classAttr = value;
|
|
26
|
-
node.invalidateStyle();
|
|
27
|
-
this.queue.enqueueStyleResolve(node);
|
|
28
|
-
// Also invalidate descendants — descendant selectors may change
|
|
29
|
-
this.invalidateDescendantStyles(node);
|
|
30
|
-
}
|
|
31
|
-
else if (key === 'id' || key === 'data-focused' || key === 'data-hovered') {
|
|
32
|
-
node.invalidateStyle();
|
|
33
|
-
this.queue.enqueueStyleResolve(node);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
this.queue.enqueuePaintOnly(node);
|
|
37
|
-
}
|
|
38
29
|
node.attributes.set(key, value);
|
|
30
|
+
this.invalidateStyles(node);
|
|
39
31
|
this.onScheduleRender?.();
|
|
40
32
|
}
|
|
41
33
|
onRemoveAttribute(node, key) {
|
|
42
|
-
node.attributes.
|
|
43
|
-
|
|
34
|
+
if (!node.attributes.has(key))
|
|
35
|
+
return; // no change
|
|
36
|
+
if (key === 'class')
|
|
44
37
|
node.cache.classAttr = '';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.invalidateDescendantStyles(node);
|
|
48
|
-
}
|
|
38
|
+
node.attributes.delete(key);
|
|
39
|
+
this.invalidateStyles(node);
|
|
49
40
|
this.onScheduleRender?.();
|
|
50
41
|
}
|
|
42
|
+
invalidateStyles(node) {
|
|
43
|
+
node.invalidateStyle();
|
|
44
|
+
this.queue.enqueueStyleResolve(node);
|
|
45
|
+
this.invalidateDescendantStyles(node);
|
|
46
|
+
}
|
|
51
47
|
onInsert(parent, child) {
|
|
52
48
|
// New node needs full computation
|
|
53
49
|
child.invalidateAll();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TermNode } from '../renderer/node.js';
|
|
2
|
+
/**
|
|
3
|
+
* After every paint, decide where the real terminal cursor should be
|
|
4
|
+
* and emit ANSI to position it.
|
|
5
|
+
*
|
|
6
|
+
* Priority:
|
|
7
|
+
* 1. Focused node owns a cursor (focused input/textarea publishes one
|
|
8
|
+
* via cache.cursorScreen) — show it at that position, or hide if
|
|
9
|
+
* the cursor cell is outside its content viewport. Region cursors
|
|
10
|
+
* stay dormant in this branch.
|
|
11
|
+
* 2. Otherwise walk the tree for an `<svt-region>` with a registered
|
|
12
|
+
* cursor (e.g. an embedded terminal mirroring its shell prompt).
|
|
13
|
+
* 3. Otherwise hide the cursor.
|
|
14
|
+
*
|
|
15
|
+
* Always returns a non-empty string — `hideCursor()` on miss — so the
|
|
16
|
+
* caller doesn't need to track transitions.
|
|
17
|
+
*/
|
|
18
|
+
export declare function emitFocusCursor(root: TermNode, focused: TermNode | null): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SvtRegionNode } from '../renderer/node.js';
|
|
2
|
+
import * as ansi from './ansi.js';
|
|
3
|
+
/**
|
|
4
|
+
* After every paint, decide where the real terminal cursor should be
|
|
5
|
+
* and emit ANSI to position it.
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. Focused node owns a cursor (focused input/textarea publishes one
|
|
9
|
+
* via cache.cursorScreen) — show it at that position, or hide if
|
|
10
|
+
* the cursor cell is outside its content viewport. Region cursors
|
|
11
|
+
* stay dormant in this branch.
|
|
12
|
+
* 2. Otherwise walk the tree for an `<svt-region>` with a registered
|
|
13
|
+
* cursor (e.g. an embedded terminal mirroring its shell prompt).
|
|
14
|
+
* 3. Otherwise hide the cursor.
|
|
15
|
+
*
|
|
16
|
+
* Always returns a non-empty string — `hideCursor()` on miss — so the
|
|
17
|
+
* caller doesn't need to track transitions.
|
|
18
|
+
*/
|
|
19
|
+
export function emitFocusCursor(root, focused) {
|
|
20
|
+
const focusedPos = focused?.getCursorScreenPos();
|
|
21
|
+
if (focusedPos) {
|
|
22
|
+
if (!focusedPos.inViewport)
|
|
23
|
+
return ansi.hideCursor() + ansi.resetCursorShape();
|
|
24
|
+
// A bar reads as "text insertion point"; everything else keeps the
|
|
25
|
+
// terminal's configured shape.
|
|
26
|
+
return ansi.moveTo(focusedPos.x + 1, focusedPos.y + 1)
|
|
27
|
+
+ ansi.setCursorShape('bar') + ansi.showCursor();
|
|
28
|
+
}
|
|
29
|
+
const regionOut = findRegionCursor(root);
|
|
30
|
+
if (regionOut)
|
|
31
|
+
return ansi.resetCursorShape() + regionOut;
|
|
32
|
+
return ansi.hideCursor() + ansi.resetCursorShape();
|
|
33
|
+
}
|
|
34
|
+
function findRegionCursor(node) {
|
|
35
|
+
if (node instanceof SvtRegionNode) {
|
|
36
|
+
const cursor = node.getCursor();
|
|
37
|
+
if (cursor) {
|
|
38
|
+
const x = node.lastBoxX + cursor.col + 1;
|
|
39
|
+
const y = node.lastBoxY + cursor.row + 1;
|
|
40
|
+
const visibility = cursor.visible ? ansi.showCursor() : ansi.hideCursor();
|
|
41
|
+
return ansi.moveTo(x, y) + visibility;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const child of node.children) {
|
|
45
|
+
const out = findRegionCursor(child);
|
|
46
|
+
if (out)
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
import { CellBuffer } from './buffer.js';
|
|
2
|
+
export interface VerticalShift {
|
|
3
|
+
/** Rows content moved: +N = scrolled up (content moved toward row 0). */
|
|
4
|
+
delta: number;
|
|
5
|
+
/** Row indices in `next` that are newly revealed (must be repainted). */
|
|
6
|
+
enteringRows: number[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Detect whether `next` is `prev` translated vertically by ±N rows over
|
|
10
|
+
* the full width — a scroll. Returns null unless the retained rows match
|
|
11
|
+
* exactly and at least one row is reused (so a scroll command saves work).
|
|
12
|
+
*/
|
|
13
|
+
export declare function detectVerticalShift(prev: CellBuffer, next: CellBuffer): VerticalShift | null;
|
|
2
14
|
export declare function diffBuffers(prev: CellBuffer | null, next: CellBuffer): string;
|
package/dist/src/render/diff.js
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
import { cellsEqual } from './buffer.js';
|
|
2
2
|
import * as ansi from './ansi.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detect whether `next` is `prev` translated vertically by ±N rows over
|
|
5
|
+
* the full width — a scroll. Returns null unless the retained rows match
|
|
6
|
+
* exactly and at least one row is reused (so a scroll command saves work).
|
|
7
|
+
*/
|
|
8
|
+
export function detectVerticalShift(prev, next) {
|
|
9
|
+
if (prev.width !== next.width || prev.height !== next.height)
|
|
10
|
+
return null;
|
|
11
|
+
const height = next.height;
|
|
12
|
+
for (let delta = 1; delta < height; delta++) {
|
|
13
|
+
if (rowsMatchShifted(prev, next, delta)) {
|
|
14
|
+
return { delta, enteringRows: range(height - delta, height) };
|
|
15
|
+
}
|
|
16
|
+
if (rowsMatchShifted(prev, next, -delta)) {
|
|
17
|
+
return { delta: -delta, enteringRows: range(0, delta) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Do next's retained rows equal prev shifted up (delta>0) or down
|
|
24
|
+
* (delta<0)? next row R comes from prev row R+delta; rows whose source
|
|
25
|
+
* falls off the buffer are the entering rows and aren't compared.
|
|
26
|
+
*/
|
|
27
|
+
function rowsMatchShifted(prev, next, delta) {
|
|
28
|
+
const height = next.height;
|
|
29
|
+
let comparedAny = false;
|
|
30
|
+
for (let row = 0; row < height; row++) {
|
|
31
|
+
const sourceRow = row + delta;
|
|
32
|
+
if (sourceRow < 0 || sourceRow >= height)
|
|
33
|
+
continue; // an entering row
|
|
34
|
+
comparedAny = true;
|
|
35
|
+
if (!rowsEqual(prev, next, sourceRow, row))
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return comparedAny;
|
|
39
|
+
}
|
|
40
|
+
function rowsEqual(a, b, aRow, bRow) {
|
|
41
|
+
for (let col = 0; col < a.width; col++) {
|
|
42
|
+
const ca = a.getCell(col, aRow);
|
|
43
|
+
const cb = b.getCell(col, bRow);
|
|
44
|
+
if (!ca || !cb || !cellsEqual(ca, cb))
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function range(start, end) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = start; i < end; i++)
|
|
52
|
+
out.push(i);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
3
55
|
export function diffBuffers(prev, next) {
|
|
56
|
+
// Whole-screen scroll: let the terminal shift rows via DECSTBM instead
|
|
57
|
+
// of rewriting every cell. Only the entering rows are painted.
|
|
58
|
+
if (prev) {
|
|
59
|
+
const shift = detectVerticalShift(prev, next);
|
|
60
|
+
if (shift)
|
|
61
|
+
return scrollDiff(next, shift);
|
|
62
|
+
}
|
|
4
63
|
const parts = [];
|
|
5
64
|
let lastStyle = null;
|
|
6
65
|
let currentHyperlink = undefined;
|
|
@@ -10,6 +69,9 @@ export function diffBuffers(prev, next) {
|
|
|
10
69
|
const prevCell = prev?.getCell(col, row);
|
|
11
70
|
if (prevCell && cellsEqual(prevCell, cell))
|
|
12
71
|
continue;
|
|
72
|
+
// Continuation cell of a wide glyph — the glyph writes it
|
|
73
|
+
if (cell.char === '')
|
|
74
|
+
continue;
|
|
13
75
|
parts.push(ansi.moveTo(col + 1, row + 1));
|
|
14
76
|
const styleCode = buildStyleCode(cell);
|
|
15
77
|
if (styleCode !== lastStyle) {
|
|
@@ -33,6 +95,62 @@ export function diffBuffers(prev, next) {
|
|
|
33
95
|
parts.push(ansi.resetStyle());
|
|
34
96
|
return parts.join('');
|
|
35
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Emit a scroll via DECSTBM: set the region to the full height, index
|
|
100
|
+
* (or reverse-index) |delta| times to shift the retained rows, then
|
|
101
|
+
* paint only the entering rows. Resets the region afterwards.
|
|
102
|
+
*/
|
|
103
|
+
function scrollDiff(next, shift) {
|
|
104
|
+
const parts = [ansi.setScrollRegion(1, next.height)];
|
|
105
|
+
const count = Math.abs(shift.delta);
|
|
106
|
+
if (shift.delta > 0) {
|
|
107
|
+
// Scrolled up: cursor to bottom, index N times (each scrolls up)
|
|
108
|
+
parts.push(ansi.moveTo(1, next.height));
|
|
109
|
+
for (let i = 0; i < count; i++)
|
|
110
|
+
parts.push(ansi.index());
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// Scrolled down: cursor to top, reverse-index N times
|
|
114
|
+
parts.push(ansi.moveTo(1, 1));
|
|
115
|
+
for (let i = 0; i < count; i++)
|
|
116
|
+
parts.push(ansi.reverseIndex());
|
|
117
|
+
}
|
|
118
|
+
parts.push(ansi.resetScrollRegion());
|
|
119
|
+
parts.push(paintRows(next, shift.enteringRows));
|
|
120
|
+
return parts.join('');
|
|
121
|
+
}
|
|
122
|
+
/** Paint the given rows in full (used for the entering rows of a scroll). */
|
|
123
|
+
function paintRows(next, rows) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
let lastStyle = null;
|
|
126
|
+
let currentHyperlink = undefined;
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
for (let col = 0; col < next.width; col++) {
|
|
129
|
+
const cell = next.getCell(col, row);
|
|
130
|
+
if (cell.char === '')
|
|
131
|
+
continue;
|
|
132
|
+
parts.push(ansi.moveTo(col + 1, row + 1));
|
|
133
|
+
const styleCode = buildStyleCode(cell);
|
|
134
|
+
if (styleCode !== lastStyle) {
|
|
135
|
+
parts.push(ansi.resetStyle(), styleCode);
|
|
136
|
+
lastStyle = styleCode;
|
|
137
|
+
}
|
|
138
|
+
if (cell.hyperlink !== currentHyperlink) {
|
|
139
|
+
if (currentHyperlink)
|
|
140
|
+
parts.push(ansi.hyperlinkClose());
|
|
141
|
+
if (cell.hyperlink)
|
|
142
|
+
parts.push(ansi.hyperlinkOpen(cell.hyperlink));
|
|
143
|
+
currentHyperlink = cell.hyperlink;
|
|
144
|
+
}
|
|
145
|
+
parts.push(cell.char);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (currentHyperlink)
|
|
149
|
+
parts.push(ansi.hyperlinkClose());
|
|
150
|
+
if (parts.length > 0)
|
|
151
|
+
parts.push(ansi.resetStyle());
|
|
152
|
+
return parts.join('');
|
|
153
|
+
}
|
|
36
154
|
function buildStyleCode(cell) {
|
|
37
155
|
const parts = [];
|
|
38
156
|
if (cell.fg !== 'default')
|
|
@@ -49,5 +167,7 @@ function buildStyleCode(cell) {
|
|
|
49
167
|
parts.push(ansi.underline());
|
|
50
168
|
if (cell.strikethrough)
|
|
51
169
|
parts.push(ansi.strikethrough());
|
|
170
|
+
if (cell.inverse)
|
|
171
|
+
parts.push(ansi.inverse());
|
|
52
172
|
return parts.join('');
|
|
53
173
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-paint generation counter. Values cached on nodes during paint
|
|
3
|
+
* (cursor positions) carry the generation that wrote them; a full paint
|
|
4
|
+
* bumps it, so anything culled that frame reads as absent instead of
|
|
5
|
+
* reporting stale coordinates. Incremental paints don't bump — nodes
|
|
6
|
+
* they skip are unchanged, so their cached values stay valid.
|
|
7
|
+
*/
|
|
8
|
+
export declare function bumpPaintGeneration(): number;
|
|
9
|
+
export declare function paintGeneration(): number;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-paint generation counter. Values cached on nodes during paint
|
|
3
|
+
* (cursor positions) carry the generation that wrote them; a full paint
|
|
4
|
+
* bumps it, so anything culled that frame reads as absent instead of
|
|
5
|
+
* reporting stale coordinates. Incremental paints don't bump — nodes
|
|
6
|
+
* they skip are unchanged, so their cached values stay valid.
|
|
7
|
+
*/
|
|
8
|
+
let generation = 0;
|
|
9
|
+
export function bumpPaintGeneration() {
|
|
10
|
+
return ++generation;
|
|
11
|
+
}
|
|
12
|
+
export function paintGeneration() {
|
|
13
|
+
return generation;
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graphics layer — post-frame kitty-image placement over `<img>` boxes.
|
|
3
|
+
* The half-block cells are still painted into the buffer (fallback and
|
|
4
|
+
* layout truth); when the terminal supports kitty graphics, this covers
|
|
5
|
+
* them with real pixels, keyed to each img node.
|
|
6
|
+
*
|
|
7
|
+
* Correctness over cleverness: each frame deletes the previous placement
|
|
8
|
+
* for every img we track and re-places the visible ones, so a moved,
|
|
9
|
+
* resized, scrolled-away, or unmounted image never leaves a ghost. Pixel
|
|
10
|
+
* data transmits once per (node, src).
|
|
11
|
+
*/
|
|
12
|
+
import { TermNode } from '../renderer/node.js';
|
|
13
|
+
import type { LayoutBox } from '../layout/engine.js';
|
|
14
|
+
export declare class GraphicsLayer {
|
|
15
|
+
private tracked;
|
|
16
|
+
private nextId;
|
|
17
|
+
/**
|
|
18
|
+
* Emit graphics commands for the current frame: clear last frame's
|
|
19
|
+
* placements, then transmit (once) and place every visible img.
|
|
20
|
+
* Returns the ANSI to write after the cell diff.
|
|
21
|
+
*/
|
|
22
|
+
render(root: TermNode, layout: Map<number, LayoutBox> | undefined): string;
|
|
23
|
+
/** Delete every active placement (teardown, suspend). */
|
|
24
|
+
clear(): string;
|
|
25
|
+
private entryFor;
|
|
26
|
+
private collectVisibleImages;
|
|
27
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graphics layer — post-frame kitty-image placement over `<img>` boxes.
|
|
3
|
+
* The half-block cells are still painted into the buffer (fallback and
|
|
4
|
+
* layout truth); when the terminal supports kitty graphics, this covers
|
|
5
|
+
* them with real pixels, keyed to each img node.
|
|
6
|
+
*
|
|
7
|
+
* Correctness over cleverness: each frame deletes the previous placement
|
|
8
|
+
* for every img we track and re-places the visible ones, so a moved,
|
|
9
|
+
* resized, scrolled-away, or unmounted image never leaves a ghost. Pixel
|
|
10
|
+
* data transmits once per (node, src).
|
|
11
|
+
*/
|
|
12
|
+
import { moveTo } from './ansi.js';
|
|
13
|
+
import { transmitImage, placeImage, deletePlacement } from './kitty-graphics.js';
|
|
14
|
+
import { imageFor } from './image.js';
|
|
15
|
+
export class GraphicsLayer {
|
|
16
|
+
tracked = new Map();
|
|
17
|
+
nextId = 1;
|
|
18
|
+
/**
|
|
19
|
+
* Emit graphics commands for the current frame: clear last frame's
|
|
20
|
+
* placements, then transmit (once) and place every visible img.
|
|
21
|
+
* Returns the ANSI to write after the cell diff.
|
|
22
|
+
*/
|
|
23
|
+
render(root, layout) {
|
|
24
|
+
const parts = [];
|
|
25
|
+
const visible = layout ? this.collectVisibleImages(root, layout) : [];
|
|
26
|
+
const visibleIds = new Set(visible.map(v => v.node.id));
|
|
27
|
+
// Clear placements that were shown last frame (moved or gone)
|
|
28
|
+
for (const [nodeId, entry] of this.tracked) {
|
|
29
|
+
if (entry.placed && !visibleIds.has(nodeId)) {
|
|
30
|
+
parts.push(deletePlacement(entry.imageId, entry.placementId));
|
|
31
|
+
entry.placed = false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const { node, box } of visible) {
|
|
35
|
+
const image = imageFor(node);
|
|
36
|
+
if (!image)
|
|
37
|
+
continue;
|
|
38
|
+
const entry = this.entryFor(node.id);
|
|
39
|
+
const src = node.attributes.get('src') ?? '';
|
|
40
|
+
if (entry.transmittedSrc !== src) {
|
|
41
|
+
parts.push(transmitImage(entry.imageId, image));
|
|
42
|
+
entry.transmittedSrc = src;
|
|
43
|
+
}
|
|
44
|
+
// Re-place every frame so it tracks the box exactly
|
|
45
|
+
if (entry.placed)
|
|
46
|
+
parts.push(deletePlacement(entry.imageId, entry.placementId));
|
|
47
|
+
parts.push(moveTo(box.x + 1, box.y + 1));
|
|
48
|
+
parts.push(placeImage(entry.imageId, entry.placementId, box.width, box.height));
|
|
49
|
+
entry.placed = true;
|
|
50
|
+
}
|
|
51
|
+
return parts.join('');
|
|
52
|
+
}
|
|
53
|
+
/** Delete every active placement (teardown, suspend). */
|
|
54
|
+
clear() {
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const entry of this.tracked.values()) {
|
|
57
|
+
if (entry.placed) {
|
|
58
|
+
parts.push(deletePlacement(entry.imageId, entry.placementId));
|
|
59
|
+
entry.placed = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return parts.join('');
|
|
63
|
+
}
|
|
64
|
+
entryFor(nodeId) {
|
|
65
|
+
let entry = this.tracked.get(nodeId);
|
|
66
|
+
if (!entry) {
|
|
67
|
+
entry = { imageId: this.nextId++, placementId: 1, transmittedSrc: null, placed: false };
|
|
68
|
+
this.tracked.set(nodeId, entry);
|
|
69
|
+
}
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
collectVisibleImages(node, layout) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const walk = (n) => {
|
|
75
|
+
if (n.nodeType === 'element' && n.tag === 'img') {
|
|
76
|
+
const box = layout.get(n.id);
|
|
77
|
+
if (box && box.width > 0 && box.height > 0 && imageFor(n))
|
|
78
|
+
out.push({ node: n, box });
|
|
79
|
+
}
|
|
80
|
+
for (const child of n.children)
|
|
81
|
+
walk(child);
|
|
82
|
+
};
|
|
83
|
+
walk(node);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <img> support: pixel store, async source loading (file path or
|
|
3
|
+
* data:image/png URI), and half-block painting — each cell shows two
|
|
4
|
+
* vertically stacked pixels via '▀' with fg = top, bg = bottom.
|
|
5
|
+
*/
|
|
6
|
+
import { TermNode } from '../renderer/node.js';
|
|
7
|
+
import { CellBuffer } from './buffer.js';
|
|
8
|
+
import { type DecodedImage } from './png.js';
|
|
9
|
+
import type { LayoutBox } from '../layout/engine.js';
|
|
10
|
+
/** The decoded image for a node, if its current src has loaded. */
|
|
11
|
+
export declare function imageFor(node: TermNode): DecodedImage | null;
|
|
12
|
+
/** Directly provide pixels (embedders, tests) — bypasses src loading. */
|
|
13
|
+
export declare function setImagePixels(node: TermNode, image: DecodedImage): void;
|
|
14
|
+
/** Kick off an async load of the node's src if not already under way. */
|
|
15
|
+
export declare function ensureImageLoading(node: TermNode): void;
|
|
16
|
+
/** Intrinsic size in cells: 1 px per column, 2 px per row (half-blocks). */
|
|
17
|
+
export declare function imageIntrinsicSize(node: TermNode): {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
} | null;
|
|
21
|
+
/** Paint the image into its box as half-blocks, nearest-neighbour scaled. */
|
|
22
|
+
export declare function paintImage(node: TermNode, buffer: CellBuffer, box: LayoutBox, clip?: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
} | null): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <img> support: pixel store, async source loading (file path or
|
|
3
|
+
* data:image/png URI), and half-block painting — each cell shows two
|
|
4
|
+
* vertically stacked pixels via '▀' with fg = top, bg = bottom.
|
|
5
|
+
*/
|
|
6
|
+
import { decodePng } from './png.js';
|
|
7
|
+
const imagesByNode = new WeakMap();
|
|
8
|
+
const loadedSrcByNode = new WeakMap();
|
|
9
|
+
/** The decoded image for a node, if its current src has loaded. */
|
|
10
|
+
export function imageFor(node) {
|
|
11
|
+
if (loadedSrcByNode.get(node) !== node.attributes.get('src'))
|
|
12
|
+
return null;
|
|
13
|
+
const state = imagesByNode.get(node);
|
|
14
|
+
return state?.kind === 'loaded' ? state.image : null;
|
|
15
|
+
}
|
|
16
|
+
/** Directly provide pixels (embedders, tests) — bypasses src loading. */
|
|
17
|
+
export function setImagePixels(node, image) {
|
|
18
|
+
imagesByNode.set(node, { kind: 'loaded', image });
|
|
19
|
+
loadedSrcByNode.set(node, node.attributes.get('src') ?? '');
|
|
20
|
+
}
|
|
21
|
+
/** Kick off an async load of the node's src if not already under way. */
|
|
22
|
+
export function ensureImageLoading(node) {
|
|
23
|
+
const src = node.attributes.get('src');
|
|
24
|
+
if (!src)
|
|
25
|
+
return;
|
|
26
|
+
if (loadedSrcByNode.get(node) === src)
|
|
27
|
+
return;
|
|
28
|
+
loadedSrcByNode.set(node, src);
|
|
29
|
+
imagesByNode.set(node, { kind: 'loading' });
|
|
30
|
+
loadSource(src).then(image => {
|
|
31
|
+
// src may have changed while decoding
|
|
32
|
+
if (loadedSrcByNode.get(node) !== src)
|
|
33
|
+
return;
|
|
34
|
+
imagesByNode.set(node, { kind: 'loaded', image });
|
|
35
|
+
invalidate(node, 'loaded');
|
|
36
|
+
}).catch(() => {
|
|
37
|
+
if (loadedSrcByNode.get(node) !== src)
|
|
38
|
+
return;
|
|
39
|
+
imagesByNode.set(node, { kind: 'failed' });
|
|
40
|
+
invalidate(node, 'failed');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pixels arriving change the element's *intrinsic size*, which no style
|
|
45
|
+
* property reflects — invalidate layout directly, not just styles.
|
|
46
|
+
*/
|
|
47
|
+
function invalidate(node, state) {
|
|
48
|
+
node.attributes.set('data-image', state);
|
|
49
|
+
if (!node.ctx)
|
|
50
|
+
return;
|
|
51
|
+
node.ctx.queue.enqueueLayoutBubble(node);
|
|
52
|
+
node.ctx.onScheduleRender?.();
|
|
53
|
+
}
|
|
54
|
+
async function loadSource(src) {
|
|
55
|
+
if (src.startsWith('data:image/png;base64,')) {
|
|
56
|
+
const base64 = src.slice('data:image/png;base64,'.length);
|
|
57
|
+
return decodePng(base64ToBytes(base64));
|
|
58
|
+
}
|
|
59
|
+
if (src.startsWith('data:'))
|
|
60
|
+
throw new Error('Only data:image/png URIs are supported');
|
|
61
|
+
const fs = await import('node:fs/promises');
|
|
62
|
+
return decodePng(new Uint8Array(await fs.readFile(src)));
|
|
63
|
+
}
|
|
64
|
+
function base64ToBytes(base64) {
|
|
65
|
+
if (typeof Buffer !== 'undefined')
|
|
66
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
67
|
+
const binary = atob(base64);
|
|
68
|
+
const out = new Uint8Array(binary.length);
|
|
69
|
+
for (let i = 0; i < binary.length; i++)
|
|
70
|
+
out[i] = binary.charCodeAt(i);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Intrinsic size in cells: 1 px per column, 2 px per row (half-blocks). */
|
|
74
|
+
export function imageIntrinsicSize(node) {
|
|
75
|
+
const image = imageFor(node);
|
|
76
|
+
if (!image)
|
|
77
|
+
return null;
|
|
78
|
+
return { width: image.width, height: Math.ceil(image.height / 2) };
|
|
79
|
+
}
|
|
80
|
+
/** Paint the image into its box as half-blocks, nearest-neighbour scaled. */
|
|
81
|
+
export function paintImage(node, buffer, box, clip) {
|
|
82
|
+
const image = imageFor(node);
|
|
83
|
+
if (!image || box.width <= 0 || box.height <= 0)
|
|
84
|
+
return;
|
|
85
|
+
const pxHeight = box.height * 2;
|
|
86
|
+
for (let row = 0; row < box.height; row++) {
|
|
87
|
+
const y = box.y + row;
|
|
88
|
+
if (clip && (y < clip.y || y >= clip.y + clip.height))
|
|
89
|
+
continue;
|
|
90
|
+
for (let col = 0; col < box.width; col++) {
|
|
91
|
+
const x = box.x + col;
|
|
92
|
+
if (clip && (x < clip.x || x >= clip.x + clip.width))
|
|
93
|
+
continue;
|
|
94
|
+
const top = sample(image, col, row * 2, box.width, pxHeight);
|
|
95
|
+
const bottom = sample(image, col, row * 2 + 1, box.width, pxHeight);
|
|
96
|
+
buffer.setCell(x, y, {
|
|
97
|
+
char: '▀',
|
|
98
|
+
fg: top,
|
|
99
|
+
bg: bottom,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Nearest-neighbour sample scaled to (targetW × targetH); hex colour. */
|
|
105
|
+
function sample(image, x, y, targetW, targetH) {
|
|
106
|
+
const sx = Math.min(image.width - 1, Math.floor((x / targetW) * image.width));
|
|
107
|
+
const sy = Math.min(image.height - 1, Math.floor((y / targetH) * image.height));
|
|
108
|
+
const at = (sy * image.width + sx) * 4;
|
|
109
|
+
if (image.rgba[at + 3] < 128)
|
|
110
|
+
return 'default'; // transparent → terminal bg
|
|
111
|
+
return '#' + [image.rgba[at], image.rgba[at + 1], image.rgba[at + 2]]
|
|
112
|
+
.map(c => c.toString(16).padStart(2, '0')).join('');
|
|
113
|
+
}
|
|
@@ -3,8 +3,12 @@ import { CellBuffer } from './buffer.js';
|
|
|
3
3
|
import { ResolvedStyle } from '../css/compute.js';
|
|
4
4
|
import { LayoutBox } from '../layout/engine.js';
|
|
5
5
|
/**
|
|
6
|
-
* Repaint only
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Repaint only the region affected by dirty nodes.
|
|
7
|
+
*
|
|
8
|
+
* Computes the union bounding box of all dirty nodes, clears that
|
|
9
|
+
* region, then does a full repaint of the entire tree clipped to
|
|
10
|
+
* that region. This correctly handles overlapping elements like
|
|
11
|
+
* parent borders, list markers in padding areas, and z-indexed
|
|
12
|
+
* siblings.
|
|
9
13
|
*/
|
|
10
14
|
export declare function paintNodes(nodes: Set<TermNode>, buffer: CellBuffer, styles: Map<number, ResolvedStyle>, layout: Map<number, LayoutBox>, root: TermNode): void;
|