@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.
Files changed (166) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/README.md +42 -29
  3. package/dist/src/cli/build.d.ts +13 -0
  4. package/dist/src/cli/build.js +119 -0
  5. package/dist/src/cli/bundle.d.ts +25 -0
  6. package/dist/src/cli/bundle.js +61 -0
  7. package/dist/src/cli/dev.d.ts +10 -0
  8. package/dist/src/cli/dev.js +152 -0
  9. package/dist/src/cli/devtools.d.ts +9 -0
  10. package/dist/src/cli/devtools.js +47 -0
  11. package/dist/src/cli/init.d.ts +8 -0
  12. package/dist/src/cli/init.js +153 -0
  13. package/dist/src/cli/main.d.ts +9 -0
  14. package/dist/src/cli/main.js +52 -0
  15. package/dist/src/cli/svt-bin.d.ts +2 -0
  16. package/dist/src/cli/svt-bin.js +6 -0
  17. package/dist/src/cli/svt.d.ts +14 -0
  18. package/dist/src/cli/svt.js +76 -0
  19. package/dist/src/components/text-buffer.js +8 -5
  20. package/dist/src/css/animation-runner.d.ts +15 -6
  21. package/dist/src/css/animation-runner.js +80 -29
  22. package/dist/src/css/animation.d.ts +12 -0
  23. package/dist/src/css/animation.js +21 -0
  24. package/dist/src/css/calc.js +4 -3
  25. package/dist/src/css/color.d.ts +19 -0
  26. package/dist/src/css/color.js +371 -62
  27. package/dist/src/css/compute.d.ts +31 -4
  28. package/dist/src/css/compute.js +273 -34
  29. package/dist/src/css/defaults.d.ts +1 -1
  30. package/dist/src/css/defaults.js +9 -0
  31. package/dist/src/css/easing.d.ts +9 -0
  32. package/dist/src/css/easing.js +95 -0
  33. package/dist/src/css/incremental.d.ts +1 -1
  34. package/dist/src/css/incremental.js +2 -2
  35. package/dist/src/css/interpolate.d.ts +13 -0
  36. package/dist/src/css/interpolate.js +41 -0
  37. package/dist/src/css/parser.js +59 -3
  38. package/dist/src/css/pseudo-elements.d.ts +9 -0
  39. package/dist/src/css/pseudo-elements.js +97 -0
  40. package/dist/src/css/selector.d.ts +17 -2
  41. package/dist/src/css/selector.js +128 -13
  42. package/dist/src/css/specificity.js +17 -6
  43. package/dist/src/css/values.d.ts +6 -1
  44. package/dist/src/css/values.js +13 -6
  45. package/dist/src/debug/context.d.ts +13 -0
  46. package/dist/src/debug/context.js +11 -0
  47. package/dist/src/debug/css.d.ts +12 -0
  48. package/dist/src/debug/css.js +28 -0
  49. package/dist/src/debug/dom.d.ts +17 -0
  50. package/dist/src/debug/dom.js +92 -0
  51. package/dist/src/devtools/DevTools.compiled.js +327 -0
  52. package/dist/src/devtools/DevTools.css.js +1 -0
  53. package/dist/src/devtools/client.d.ts +36 -0
  54. package/dist/src/devtools/client.js +76 -0
  55. package/dist/src/framelog.d.ts +54 -0
  56. package/dist/src/framelog.js +99 -0
  57. package/dist/src/headless.js +12 -4
  58. package/dist/src/index.d.ts +66 -3
  59. package/dist/src/index.js +610 -81
  60. package/dist/src/input/checkable.d.ts +8 -0
  61. package/dist/src/input/checkable.js +66 -0
  62. package/dist/src/input/details.d.ts +6 -0
  63. package/dist/src/input/details.js +34 -0
  64. package/dist/src/input/focus.d.ts +6 -0
  65. package/dist/src/input/focus.js +27 -9
  66. package/dist/src/input/keyboard.d.ts +2 -2
  67. package/dist/src/input/keyboard.js +32 -5
  68. package/dist/src/input/label.d.ts +8 -0
  69. package/dist/src/input/label.js +53 -0
  70. package/dist/src/input/modal.d.ts +9 -0
  71. package/dist/src/input/modal.js +28 -0
  72. package/dist/src/input/mouse.d.ts +2 -2
  73. package/dist/src/input/mouse.js +15 -2
  74. package/dist/src/input/select.d.ts +12 -0
  75. package/dist/src/input/select.js +63 -0
  76. package/dist/src/input/selection.d.ts +48 -0
  77. package/dist/src/input/selection.js +150 -0
  78. package/dist/src/layout/engine.d.ts +2 -0
  79. package/dist/src/layout/engine.js +1092 -142
  80. package/dist/src/layout/flex.js +4 -4
  81. package/dist/src/layout/size.js +3 -2
  82. package/dist/src/layout/text.d.ts +3 -2
  83. package/dist/src/layout/text.js +96 -17
  84. package/dist/src/layout/unicode.d.ts +20 -0
  85. package/dist/src/layout/unicode.js +121 -0
  86. package/dist/src/render/animation-clock.d.ts +57 -0
  87. package/dist/src/render/animation-clock.js +221 -0
  88. package/dist/src/render/ansi-text.d.ts +26 -0
  89. package/dist/src/render/ansi-text.js +131 -0
  90. package/dist/src/render/ansi.d.ts +18 -0
  91. package/dist/src/render/ansi.js +64 -19
  92. package/dist/src/render/border.js +166 -17
  93. package/dist/src/render/buffer.d.ts +1 -0
  94. package/dist/src/render/buffer.js +5 -2
  95. package/dist/src/render/clock.d.ts +35 -0
  96. package/dist/src/render/clock.js +67 -0
  97. package/dist/src/render/color-depth.d.ts +8 -0
  98. package/dist/src/render/color-depth.js +59 -0
  99. package/dist/src/render/context.d.ts +1 -0
  100. package/dist/src/render/context.js +17 -21
  101. package/dist/src/render/cursor-emit.d.ts +18 -0
  102. package/dist/src/render/cursor-emit.js +50 -0
  103. package/dist/src/render/diff.d.ts +12 -0
  104. package/dist/src/render/diff.js +120 -0
  105. package/dist/src/render/generation.d.ts +9 -0
  106. package/dist/src/render/generation.js +14 -0
  107. package/dist/src/render/graphics-layer.d.ts +27 -0
  108. package/dist/src/render/graphics-layer.js +86 -0
  109. package/dist/src/render/image.d.ts +27 -0
  110. package/dist/src/render/image.js +113 -0
  111. package/dist/src/render/incremental-paint.d.ts +7 -3
  112. package/dist/src/render/incremental-paint.js +52 -79
  113. package/dist/src/render/inline.d.ts +59 -0
  114. package/dist/src/render/inline.js +219 -0
  115. package/dist/src/render/kitty-graphics.d.ts +24 -0
  116. package/dist/src/render/kitty-graphics.js +58 -0
  117. package/dist/src/render/paint-text.js +68 -22
  118. package/dist/src/render/paint.d.ts +8 -1
  119. package/dist/src/render/paint.js +358 -31
  120. package/dist/src/render/png.d.ts +13 -0
  121. package/dist/src/render/png.js +145 -0
  122. package/dist/src/render/scrollbar.d.ts +8 -2
  123. package/dist/src/render/scrollbar.js +71 -14
  124. package/dist/src/render/snapshot.js +3 -1
  125. package/dist/src/renderer/default.d.ts +7 -0
  126. package/dist/src/renderer/default.js +11 -0
  127. package/dist/src/renderer/index.d.ts +8 -2
  128. package/dist/src/renderer/index.js +4 -2
  129. package/dist/src/renderer/node.d.ts +109 -0
  130. package/dist/src/renderer/node.js +165 -1
  131. package/dist/src/terminal/capabilities.d.ts +33 -0
  132. package/dist/src/terminal/capabilities.js +66 -0
  133. package/dist/src/terminal/clipboard.d.ts +9 -0
  134. package/dist/src/terminal/clipboard.js +39 -0
  135. package/dist/src/terminal/io.d.ts +82 -0
  136. package/dist/src/terminal/io.js +155 -0
  137. package/dist/src/terminal/screen.d.ts +3 -10
  138. package/dist/src/terminal/screen.js +5 -28
  139. package/dist/src/terminal/stdin-router.d.ts +8 -5
  140. package/dist/src/terminal/stdin-router.js +22 -11
  141. package/dist/src/utils/node-map.d.ts +24 -0
  142. package/dist/src/utils/node-map.js +75 -0
  143. package/dist/src/vite/config.d.ts +62 -0
  144. package/dist/src/vite/config.js +191 -0
  145. package/docs/compatibility.md +67 -0
  146. package/docs/debug/devtools.md +40 -0
  147. package/docs/debug/svt.md +50 -0
  148. package/docs/distribution.md +106 -0
  149. package/docs/elements.md +120 -0
  150. package/docs/getting-started.md +177 -0
  151. package/docs/guide/css.md +187 -0
  152. package/docs/guide/input.md +143 -0
  153. package/docs/guide/layout.md +171 -0
  154. package/docs/guide/theming.md +94 -0
  155. package/docs/how-it-works.md +115 -0
  156. package/docs/inline-mode.md +77 -0
  157. package/docs/layout.md +112 -0
  158. package/docs/motion.md +91 -0
  159. package/docs/reference/README.md +65 -0
  160. package/docs/reference/css/properties/border-corner.md +82 -0
  161. package/docs/reference/css/properties/border-style.md +168 -0
  162. package/docs/reference.md +227 -0
  163. package/docs/selectors.md +80 -0
  164. package/docs/terminal-css.md +149 -0
  165. package/docs/terminals.md +83 -0
  166. package/package.json +28 -7
@@ -1,94 +1,67 @@
1
- import { renderBorder } from './border.js';
2
- import { paintTextContent } from './paint-text.js';
1
+ import { paint } from './paint.js';
3
2
  /**
4
- * Repaint only specific nodes' cells in the buffer.
5
- * Clears the old area and writes the new content.
6
- * Handles text-align, text-overflow, and white-space from ancestors.
3
+ * Repaint only the region affected by dirty nodes.
4
+ *
5
+ * Computes the union bounding box of all dirty nodes, clears that
6
+ * region, then does a full repaint of the entire tree clipped to
7
+ * that region. This correctly handles overlapping elements like
8
+ * parent borders, list markers in padding areas, and z-indexed
9
+ * siblings.
7
10
  */
8
11
  export function paintNodes(nodes, buffer, styles, layout, root) {
12
+ if (nodes.size === 0)
13
+ return;
14
+ // Compute dirty region — union of all dirty nodes' current and previous boxes
15
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
9
16
  for (const node of nodes) {
10
17
  const box = layout.get(node.id);
11
- if (!box)
12
- continue;
13
- const oldBox = node.cache.layoutBox;
14
- if (oldBox)
15
- clearArea(buffer, oldBox);
16
- if (node.nodeType === 'text') {
17
- paintTextShared(node, buffer, box, styles, layout);
18
+ if (box) {
19
+ minX = Math.min(minX, box.x);
20
+ minY = Math.min(minY, box.y);
21
+ maxX = Math.max(maxX, box.x + box.width);
22
+ maxY = Math.max(maxY, box.y + box.height);
18
23
  }
19
- else if (node.nodeType === 'element') {
20
- paintElementNode(node, buffer, box, styles, layout);
24
+ const oldBox = node.cache.layoutBox;
25
+ if (oldBox) {
26
+ minX = Math.min(minX, oldBox.x);
27
+ minY = Math.min(minY, oldBox.y);
28
+ maxX = Math.max(maxX, oldBox.x + oldBox.width);
29
+ maxY = Math.max(maxY, oldBox.y + oldBox.height);
21
30
  }
22
31
  }
23
- }
24
- function paintElementNode(node, buffer, box, styles, layout) {
25
- const style = styles.get(node.id);
26
- if (!style || style.display === 'none')
32
+ if (minX >= maxX || minY >= maxY)
27
33
  return;
28
- // Background fill
29
- const visuals = resolveInheritedVisuals(node, styles);
30
- // Element's own style overrides inherited
31
- const bg = style.bg !== 'default' ? style.bg : visuals.bg;
32
- if (bg !== 'default') {
33
- for (let row = box.y; row < box.y + box.height; row++) {
34
- for (let col = box.x; col < box.x + box.width; col++) {
35
- buffer.setCell(col, row, { bg });
34
+ // Expand dirty region to include parent borders and list marker padding
35
+ // that may overlap the dirty area
36
+ for (const node of nodes) {
37
+ let parent = node.parent;
38
+ while (parent) {
39
+ const parentBox = layout.get(parent.id);
40
+ if (parentBox) {
41
+ minX = Math.min(minX, parentBox.x);
42
+ minY = Math.min(minY, parentBox.y);
43
+ maxX = Math.max(maxX, parentBox.x + parentBox.width);
44
+ maxY = Math.max(maxY, parentBox.y + parentBox.height);
36
45
  }
46
+ parent = parent.parent;
37
47
  }
38
48
  }
39
- // Border
40
- if (style.borderStyle !== 'none') {
41
- renderBorder(buffer, box, style);
42
- }
43
- // Repaint text children
44
- for (const child of node.children) {
45
- const childBox = layout.get(child.id);
46
- if (!childBox)
47
- continue;
48
- if (child.nodeType === 'text') {
49
- paintTextShared(child, buffer, childBox, styles, layout);
50
- }
51
- else if (child.nodeType === 'element') {
52
- paintElementNode(child, buffer, childBox, styles, layout);
53
- }
54
- }
55
- }
56
- function paintTextShared(node, buffer, box, styles, layout) {
57
- const visuals = resolveInheritedVisuals(node, styles);
58
- paintTextContent(node, buffer, box, visuals, styles, layout);
59
- }
60
- function clearArea(buffer, box) {
61
- for (let row = box.y; row < box.y + box.height; row++) {
62
- for (let col = box.x; col < box.x + box.width; col++) {
63
- buffer.setCell(col, row, { char: ' ', fg: 'default', bg: 'default', bold: false, italic: false, underline: false, strikethrough: false, dim: false });
64
- }
65
- }
66
- }
67
- function resolveInheritedVisuals(node, styles) {
68
- const result = { fg: 'default', bg: 'default', bold: false, italic: false, underline: false, strikethrough: false, dim: false };
69
- let current = node.parent;
70
- while (current) {
71
- const style = styles.get(current.id);
72
- if (style) {
73
- if (result.fg === 'default' && style.fg !== 'default')
74
- result.fg = style.fg;
75
- if (result.bg === 'default' && style.bg !== 'default')
76
- result.bg = style.bg;
77
- if (!result.bold && style.bold)
78
- result.bold = true;
79
- if (!result.italic && style.italic)
80
- result.italic = true;
81
- if (!result.underline && style.underline)
82
- result.underline = true;
83
- if (!result.strikethrough && style.strikethrough)
84
- result.strikethrough = true;
85
- if (!result.dim && style.dim)
86
- result.dim = true;
87
- }
88
- if (!result.hyperlink && current.tag === 'a') {
89
- result.hyperlink = current.attributes.get('href');
49
+ // Clamp to buffer bounds
50
+ minX = Math.max(0, minX);
51
+ minY = Math.max(0, minY);
52
+ maxX = Math.min(buffer.width, maxX);
53
+ maxY = Math.min(buffer.height, maxY);
54
+ // Clear the dirty region
55
+ for (let row = minY; row < maxY; row++) {
56
+ for (let col = minX; col < maxX; col++) {
57
+ buffer.setCell(col, row, {
58
+ char: ' ', fg: 'default', bg: 'default',
59
+ bold: false, italic: false, underline: false,
60
+ strikethrough: false, dim: false, inverse: false,
61
+ });
90
62
  }
91
- current = current.parent;
92
63
  }
93
- return result;
64
+ // Full repaint of the entire tree, clipped to the dirty region
65
+ const clip = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
66
+ paint(root, buffer, styles, layout, clip);
94
67
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Inline-mode screen driver. The app renders into the main buffer at the
3
+ * shell's cursor position; rows above the render origin belong to the
4
+ * terminal's scrollback and are never touched again. All cursor movement
5
+ * is relative — the origin's absolute position is unknown by design.
6
+ *
7
+ * Growth emits real newlines at the bottom row (LF scrolls where CUD
8
+ * cannot); shrinking erases to end of screen; archiving (`releaseTop`)
9
+ * just moves the comparison window down — the released rows are already
10
+ * on the terminal exactly as they should stay.
11
+ */
12
+ import { CellBuffer } from './buffer.js';
13
+ export declare class InlineScreen {
14
+ /** Last painted content, padded with blanks to `physicalRows`. */
15
+ private prev;
16
+ /** Lines the zone has realised on the terminal. */
17
+ private physicalRows;
18
+ /** Rows the last buffer actually occupied (≤ physicalRows). */
19
+ private contentHeight;
20
+ /** Cursor position relative to the live-zone origin. */
21
+ private cursorRow;
22
+ /** -1 when unknown (wrap-pending after writing the last column). */
23
+ private cursorCol;
24
+ /** 1-based screen row of zone row 0, from a CPR query; null = unknown. */
25
+ private originRow;
26
+ /** ANSI that makes the terminal's live zone match `next`. */
27
+ render(next: CellBuffer): string;
28
+ /** Record where the zone starts on screen (1-based, from CPR). */
29
+ setOriginRow(row: number): void;
30
+ /** The cursor's current row within the zone (for CPR origin math). */
31
+ cursorZoneRow(): number;
32
+ /**
33
+ * Map a 0-based screen row (mouse coordinates) to a zone row, or
34
+ * null when unknown or outside the live content. Growth past the
35
+ * screen bottom scrolls the zone up, so the effective origin is
36
+ * clamped to keep the zone's bottom on screen.
37
+ */
38
+ screenRowToZone(screenRow: number, screenHeight: number): number | null;
39
+ /**
40
+ * Forget the zone entirely — after a suspend the shell owned the
41
+ * screen, so the next render starts a fresh zone at the cursor.
42
+ */
43
+ reset(): void;
44
+ /** Hand the top `n` rows to the terminal's scrollback. */
45
+ releaseTop(n: number): void;
46
+ /** Place the terminal cursor at live-zone coordinates. */
47
+ moveCursorTo(col: number, row: number): string;
48
+ /** Leave the cursor on a fresh line after the content. */
49
+ finish(): string;
50
+ /** Relative row movement, tracking the new position. */
51
+ private moveRow;
52
+ /** Realise new physical lines with LF so the terminal scrolls. */
53
+ private grow;
54
+ /**
55
+ * Blank everything below `height`. The physical lines stay realised
56
+ * (as empties) so regrowth reuses them instead of scrolling new ones.
57
+ */
58
+ private eraseBelow;
59
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Inline-mode screen driver. The app renders into the main buffer at the
3
+ * shell's cursor position; rows above the render origin belong to the
4
+ * terminal's scrollback and are never touched again. All cursor movement
5
+ * is relative — the origin's absolute position is unknown by design.
6
+ *
7
+ * Growth emits real newlines at the bottom row (LF scrolls where CUD
8
+ * cannot); shrinking erases to end of screen; archiving (`releaseTop`)
9
+ * just moves the comparison window down — the released rows are already
10
+ * on the terminal exactly as they should stay.
11
+ */
12
+ import { CellBuffer, cellsEqual } from './buffer.js';
13
+ import * as ansi from './ansi.js';
14
+ import { stringWidth } from '../layout/unicode.js';
15
+ const CSI = '\x1b[';
16
+ export class InlineScreen {
17
+ /** Last painted content, padded with blanks to `physicalRows`. */
18
+ prev = null;
19
+ /** Lines the zone has realised on the terminal. */
20
+ physicalRows = 0;
21
+ /** Rows the last buffer actually occupied (≤ physicalRows). */
22
+ contentHeight = 0;
23
+ /** Cursor position relative to the live-zone origin. */
24
+ cursorRow = 0;
25
+ /** -1 when unknown (wrap-pending after writing the last column). */
26
+ cursorCol = 0;
27
+ /** 1-based screen row of zone row 0, from a CPR query; null = unknown. */
28
+ originRow = null;
29
+ /** ANSI that makes the terminal's live zone match `next`. */
30
+ render(next) {
31
+ const parts = [];
32
+ if (this.prev && this.prev.width !== next.width) {
33
+ // Width changed: the terminal may have rewrapped our rows.
34
+ // Erase and repaint the whole zone in place — best effort.
35
+ parts.push(this.moveRow(0), '\r', `${CSI}0J`);
36
+ this.cursorCol = 0;
37
+ this.prev = null;
38
+ }
39
+ if (next.height > this.physicalRows)
40
+ parts.push(this.grow(next.height));
41
+ if (next.height < this.contentHeight)
42
+ parts.push(this.eraseBelow(next.height));
43
+ let lastStyle = null;
44
+ for (let row = 0; row < next.height; row++) {
45
+ for (let col = 0; col < next.width; col++) {
46
+ const cell = next.getCell(col, row);
47
+ const prevCell = this.prev?.getCell(col, row);
48
+ if (prevCell && cellsEqual(prevCell, cell))
49
+ continue;
50
+ // Continuation cell of a wide glyph — the glyph writes it
51
+ if (cell.char === '')
52
+ continue;
53
+ if (this.cursorRow !== row || this.cursorCol !== col) {
54
+ parts.push(this.moveRow(row), `${CSI}${col + 1}G`);
55
+ }
56
+ const styleCode = buildStyleCode(cell);
57
+ if (styleCode !== lastStyle) {
58
+ parts.push(ansi.resetStyle(), styleCode);
59
+ lastStyle = styleCode;
60
+ }
61
+ parts.push(cell.char);
62
+ const advance = Math.max(1, stringWidth(cell.char));
63
+ this.cursorCol = col + advance >= next.width ? -1 : col + advance;
64
+ }
65
+ }
66
+ if (lastStyle !== null)
67
+ parts.push(ansi.resetStyle());
68
+ this.prev = padToHeight(next, this.physicalRows);
69
+ this.contentHeight = next.height;
70
+ return parts.join('');
71
+ }
72
+ /** Record where the zone starts on screen (1-based, from CPR). */
73
+ setOriginRow(row) {
74
+ this.originRow = row;
75
+ }
76
+ /** The cursor's current row within the zone (for CPR origin math). */
77
+ cursorZoneRow() {
78
+ return Math.max(0, this.cursorRow);
79
+ }
80
+ /**
81
+ * Map a 0-based screen row (mouse coordinates) to a zone row, or
82
+ * null when unknown or outside the live content. Growth past the
83
+ * screen bottom scrolls the zone up, so the effective origin is
84
+ * clamped to keep the zone's bottom on screen.
85
+ */
86
+ screenRowToZone(screenRow, screenHeight) {
87
+ if (this.originRow === null)
88
+ return null;
89
+ const effectiveOrigin = Math.min(this.originRow, screenHeight - this.physicalRows + 1);
90
+ const zoneRow = screenRow - (effectiveOrigin - 1);
91
+ if (zoneRow < 0 || zoneRow >= this.contentHeight)
92
+ return null;
93
+ return zoneRow;
94
+ }
95
+ /**
96
+ * Forget the zone entirely — after a suspend the shell owned the
97
+ * screen, so the next render starts a fresh zone at the cursor.
98
+ */
99
+ reset() {
100
+ this.prev = null;
101
+ this.physicalRows = 0;
102
+ this.contentHeight = 0;
103
+ this.cursorRow = 0;
104
+ this.cursorCol = 0;
105
+ this.originRow = null;
106
+ }
107
+ /** Hand the top `n` rows to the terminal's scrollback. */
108
+ releaseTop(n) {
109
+ if (n <= 0)
110
+ return;
111
+ const count = Math.min(n, this.contentHeight);
112
+ if (this.prev)
113
+ this.prev = dropTopRows(this.prev, count);
114
+ this.physicalRows -= count;
115
+ this.contentHeight -= count;
116
+ this.cursorRow -= count;
117
+ if (this.originRow !== null)
118
+ this.originRow += count;
119
+ }
120
+ /** Place the terminal cursor at live-zone coordinates. */
121
+ moveCursorTo(col, row) {
122
+ const out = this.moveRow(row) + `${CSI}${col + 1}G`;
123
+ this.cursorCol = col;
124
+ return out;
125
+ }
126
+ /** Leave the cursor on a fresh line after the content. */
127
+ finish() {
128
+ let out;
129
+ if (this.contentHeight < this.physicalRows) {
130
+ out = this.moveRow(this.contentHeight) + '\r';
131
+ }
132
+ else {
133
+ out = this.moveRow(Math.max(0, this.physicalRows - 1)) + '\r\n';
134
+ this.cursorRow = this.physicalRows;
135
+ }
136
+ this.cursorCol = 0;
137
+ return out + ansi.showCursor();
138
+ }
139
+ /** Relative row movement, tracking the new position. */
140
+ moveRow(row) {
141
+ const delta = row - this.cursorRow;
142
+ this.cursorRow = row;
143
+ if (delta === 0)
144
+ return '';
145
+ return delta > 0 ? `${CSI}${delta}B` : `${CSI}${-delta}A`;
146
+ }
147
+ /** Realise new physical lines with LF so the terminal scrolls. */
148
+ grow(height) {
149
+ const parts = [];
150
+ if (this.physicalRows > 0)
151
+ parts.push(this.moveRow(this.physicalRows - 1));
152
+ parts.push('\r');
153
+ const count = this.physicalRows === 0 ? height - 1 : height - this.physicalRows;
154
+ for (let i = 0; i < count; i++)
155
+ parts.push('\n');
156
+ this.cursorRow = height - 1;
157
+ this.cursorCol = 0;
158
+ this.physicalRows = height;
159
+ this.prev = this.prev && padToHeight(this.prev, height);
160
+ return parts.join('');
161
+ }
162
+ /**
163
+ * Blank everything below `height`. The physical lines stay realised
164
+ * (as empties) so regrowth reuses them instead of scrolling new ones.
165
+ */
166
+ eraseBelow(height) {
167
+ const out = this.moveRow(height) + '\r' + `${CSI}0J`;
168
+ this.cursorCol = 0;
169
+ if (this.prev)
170
+ this.prev = blankBelow(this.prev, height);
171
+ return out;
172
+ }
173
+ }
174
+ function padToHeight(buffer, height) {
175
+ if (buffer.height >= height)
176
+ return buffer;
177
+ const next = new CellBuffer(buffer.width, height);
178
+ copyRows(buffer, next, 0, buffer.height, 0);
179
+ return next;
180
+ }
181
+ function dropTopRows(buffer, count) {
182
+ const next = new CellBuffer(buffer.width, Math.max(0, buffer.height - count));
183
+ copyRows(buffer, next, count, buffer.height, -count);
184
+ return next;
185
+ }
186
+ function blankBelow(buffer, fromRow) {
187
+ const next = new CellBuffer(buffer.width, buffer.height);
188
+ copyRows(buffer, next, 0, Math.min(fromRow, buffer.height), 0);
189
+ return next;
190
+ }
191
+ function copyRows(from, to, start, end, offset) {
192
+ for (let row = start; row < end; row++) {
193
+ for (let col = 0; col < from.width; col++) {
194
+ const cell = from.getCell(col, row);
195
+ if (cell)
196
+ to.setCell(col, row + offset, cell);
197
+ }
198
+ }
199
+ }
200
+ function buildStyleCode(cell) {
201
+ const parts = [];
202
+ if (cell.fg !== 'default')
203
+ parts.push(ansi.fgColor(cell.fg));
204
+ if (cell.bg !== 'default')
205
+ parts.push(ansi.bgColor(cell.bg));
206
+ if (cell.bold)
207
+ parts.push(ansi.bold());
208
+ if (cell.dim)
209
+ parts.push(ansi.dim());
210
+ if (cell.italic)
211
+ parts.push(ansi.italic());
212
+ if (cell.underline)
213
+ parts.push(ansi.underline());
214
+ if (cell.strikethrough)
215
+ parts.push(ansi.strikethrough());
216
+ if (cell.inverse)
217
+ parts.push(ansi.inverse());
218
+ return parts.join('');
219
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Kitty graphics protocol: transmit RGBA pixels and place them scaled to
3
+ * a cell box, for crisp `<img>` rendering on supporting terminals. The
4
+ * half-block path (render/image.ts) stays the default; this activates
5
+ * only when capability detection reports graphics support.
6
+ *
7
+ * Protocol: commands are APC sequences `ESC _ G <key=val,...> ; <base64>
8
+ * ESC \`. Payloads chunk into ≤4096 base64 chars with `m=1` on every
9
+ * segment but the last.
10
+ */
11
+ import type { DecodedImage } from './png.js';
12
+ export declare function graphicsSupported(xtversion: string | null): boolean;
13
+ /**
14
+ * Transmit (but don't display) an image's pixels under an id, for later
15
+ * placement. Chunked so no single APC exceeds the protocol limit.
16
+ */
17
+ export declare function transmitImage(imageId: number, image: DecodedImage): string;
18
+ /**
19
+ * Place a transmitted image at the current cursor position, scaled to
20
+ * `cols`×`rows` cells, without moving the cursor (`C=1`).
21
+ */
22
+ export declare function placeImage(imageId: number, placementId: number, cols: number, rows: number): string;
23
+ /** Delete one placement, leaving the transmitted image data intact. */
24
+ export declare function deletePlacement(imageId: number, placementId: number): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Kitty graphics protocol: transmit RGBA pixels and place them scaled to
3
+ * a cell box, for crisp `<img>` rendering on supporting terminals. The
4
+ * half-block path (render/image.ts) stays the default; this activates
5
+ * only when capability detection reports graphics support.
6
+ *
7
+ * Protocol: commands are APC sequences `ESC _ G <key=val,...> ; <base64>
8
+ * ESC \`. Payloads chunk into ≤4096 base64 chars with `m=1` on every
9
+ * segment but the last.
10
+ */
11
+ const APC = '\x1b_G';
12
+ const ST = '\x1b\\';
13
+ const CHUNK = 4096;
14
+ /** Terminals that speak the kitty graphics protocol (by XTVERSION name). */
15
+ const GRAPHICS_TERMINALS = /kitty|ghostty|wezterm/i;
16
+ export function graphicsSupported(xtversion) {
17
+ return xtversion !== null && GRAPHICS_TERMINALS.test(xtversion);
18
+ }
19
+ /** Base64 of the image's RGBA bytes. */
20
+ function encodePayload(image) {
21
+ if (typeof Buffer !== 'undefined')
22
+ return Buffer.from(image.rgba).toString('base64');
23
+ let binary = '';
24
+ for (const byte of image.rgba)
25
+ binary += String.fromCharCode(byte);
26
+ return btoa(binary);
27
+ }
28
+ /**
29
+ * Transmit (but don't display) an image's pixels under an id, for later
30
+ * placement. Chunked so no single APC exceeds the protocol limit.
31
+ */
32
+ export function transmitImage(imageId, image) {
33
+ const payload = encodePayload(image);
34
+ const control = `a=t,f=32,i=${imageId},s=${image.width},v=${image.height}`;
35
+ if (payload.length <= CHUNK) {
36
+ return `${APC}${control},m=0;${payload}${ST}`;
37
+ }
38
+ const parts = [];
39
+ for (let offset = 0; offset < payload.length; offset += CHUNK) {
40
+ const chunk = payload.slice(offset, offset + CHUNK);
41
+ const more = offset + CHUNK < payload.length ? 1 : 0;
42
+ // The control keys only need to appear on the first chunk
43
+ const head = offset === 0 ? `${control},m=${more}` : `m=${more}`;
44
+ parts.push(`${APC}${head};${chunk}${ST}`);
45
+ }
46
+ return parts.join('');
47
+ }
48
+ /**
49
+ * Place a transmitted image at the current cursor position, scaled to
50
+ * `cols`×`rows` cells, without moving the cursor (`C=1`).
51
+ */
52
+ export function placeImage(imageId, placementId, cols, rows) {
53
+ return `${APC}a=p,i=${imageId},p=${placementId},c=${cols},r=${rows},C=1;${ST}`;
54
+ }
55
+ /** Delete one placement, leaving the transmitted image data intact. */
56
+ export function deletePlacement(imageId, placementId) {
57
+ return `${APC}a=d,d=i,i=${imageId},p=${placementId};${ST}`;
58
+ }
@@ -1,42 +1,60 @@
1
- import { wrapText, truncateText } from '../layout/text.js';
1
+ import { wrapText, truncateText, truncateMiddle } from '../layout/text.js';
2
+ import { graphemes, charWidth, stringWidth } from '../layout/unicode.js';
3
+ import { parseAnsiText } from './ansi-text.js';
4
+ import { blendColor } from '../css/color.js';
2
5
  /**
3
6
  * Paint a text node's content into the buffer, respecting inherited
4
7
  * text-align, white-space, text-overflow from ancestors.
5
8
  */
6
9
  export function paintTextContent(node, buffer, box, visuals, styles, layout, clip) {
7
- const text = node.text ?? '';
10
+ let text = node.text ?? '';
8
11
  if (!text)
9
12
  return;
10
13
  if (box.width === 0 && box.height === 0)
11
14
  return;
15
+ // <svt-ansi> content is pre-styled — its own SGR codes win
16
+ if (node.parent?.tag === 'svt-ansi') {
17
+ paintAnsiContent(text, buffer, box, clip);
18
+ return;
19
+ }
12
20
  // Find text properties from the ancestor that sets them
13
21
  const alignResult = findAncestorWithBox(node, styles, layout, s => s.textAlign !== 'left' ? s.textAlign : undefined);
14
22
  const align = alignResult?.value ?? 'left';
15
23
  const whiteSpace = findAncestorProp(node, styles, s => s.whiteSpace !== 'normal' ? s.whiteSpace : undefined) ?? 'normal';
16
24
  const textOverflow = findAncestorProp(node, styles, s => s.textOverflow !== 'clip' ? s.textOverflow : undefined) ?? 'clip';
25
+ const textTransform = findAncestorProp(node, styles, s => s.textTransform !== 'none' ? s.textTransform : undefined);
26
+ if (textTransform === 'uppercase')
27
+ text = text.toUpperCase();
28
+ else if (textTransform === 'lowercase')
29
+ text = text.toLowerCase();
30
+ else if (textTransform === 'capitalize')
31
+ text = text.replace(/\b\w/g, c => c.toUpperCase());
17
32
  const noWrap = whiteSpace === 'nowrap';
18
- const ellipsis = textOverflow === 'ellipsis';
33
+ const wordBreak = findAncestorProp(node, styles, s => s.wordBreak !== 'normal' ? s.wordBreak : undefined) ?? 'normal';
19
34
  // For truncation, use the alignment container's inner width
20
35
  const alignBox = alignResult?.box;
21
36
  const parentBox = node.parent ? layout.get(node.parent.id) : undefined;
22
37
  const truncWidth = alignBox ? innerWidth(alignBox, node, styles, layout) : (parentBox?.width ?? box.width);
23
38
  // Determine text lines
24
39
  let lines;
25
- if (noWrap && ellipsis) {
40
+ if (noWrap && textOverflow === 'ellipsis') {
26
41
  lines = [truncateText(text, truncWidth)];
27
42
  }
43
+ else if (noWrap && textOverflow === 'ellipsis-middle') {
44
+ lines = [truncateMiddle(text, truncWidth)];
45
+ }
28
46
  else if (noWrap) {
29
47
  lines = [text.substring(0, truncWidth)];
30
48
  }
31
49
  else {
32
- lines = wrapText(text, box.width > 0 ? box.width : buffer.width);
50
+ lines = wrapText(text, box.width > 0 ? box.width : buffer.width, wordBreak);
33
51
  }
34
52
  // Compute starting x with text-align
35
53
  let startX = box.x;
36
54
  if (align !== 'left' && alignBox) {
37
55
  const inW = innerWidth(alignBox, node, styles, layout);
38
56
  const inX = innerX(alignBox, node, styles, layout);
39
- const textWidth = lines[0]?.length ?? 0;
57
+ const textWidth = lines[0] ? stringWidth(lines[0]) : 0;
40
58
  if (align === 'center') {
41
59
  startX = inX + Math.floor((inW - textWidth) / 2);
42
60
  }
@@ -44,24 +62,37 @@ export function paintTextContent(node, buffer, box, visuals, styles, layout, cli
44
62
  startX = inX + inW - textWidth;
45
63
  }
46
64
  }
65
+ const fgHasAlpha = visuals.fg.startsWith('#') && visuals.fg.length === 9;
66
+ const bgHasAlpha = visuals.bg.startsWith('#') && visuals.bg.length === 9;
47
67
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
48
- const line = lines[lineIdx];
49
68
  const y = box.y + lineIdx;
50
- for (let i = 0; i < line.length; i++) {
51
- const cx = startX + i;
52
- if (clip && (cx < clip.x || cx >= clip.x + clip.width || y < clip.y || y >= clip.y + clip.height))
53
- continue;
54
- buffer.setCell(cx, y, {
55
- char: line[i],
56
- fg: visuals.fg,
57
- bg: visuals.bg,
58
- bold: visuals.bold,
59
- italic: visuals.italic,
60
- underline: visuals.underline,
61
- strikethrough: visuals.strikethrough,
62
- dim: visuals.dim,
63
- hyperlink: visuals.hyperlink,
64
- });
69
+ let cx = startX;
70
+ for (const glyph of graphemes(lines[lineIdx])) {
71
+ const width = Math.max(1, charWidth(glyph));
72
+ const cells = [{ x: cx, char: glyph }];
73
+ // A wide glyph owns its neighbour cell via an empty continuation
74
+ for (let extra = 1; extra < width; extra++)
75
+ cells.push({ x: cx + extra, char: '' });
76
+ for (const { x, char } of cells) {
77
+ if (clip && (x < clip.x || x >= clip.x + clip.width || y < clip.y || y >= clip.y + clip.height))
78
+ continue;
79
+ // An alpha bg was already composited by the ancestor's fill —
80
+ // the cell beneath holds the blended value; don't blend twice.
81
+ const under = buffer.getCell(x, y)?.bg ?? 'default';
82
+ const bg = bgHasAlpha ? under : visuals.bg;
83
+ buffer.setCell(x, y, {
84
+ char,
85
+ fg: fgHasAlpha ? blendColor(bg !== 'default' ? bg : under, visuals.fg) : visuals.fg,
86
+ bg,
87
+ bold: visuals.bold,
88
+ italic: visuals.italic,
89
+ underline: visuals.underline,
90
+ strikethrough: visuals.strikethrough,
91
+ dim: visuals.dim,
92
+ hyperlink: visuals.hyperlink,
93
+ });
94
+ }
95
+ cx += width;
65
96
  }
66
97
  }
67
98
  }
@@ -106,6 +137,21 @@ function innerWidth(alignBox, node, styles, layout) {
106
137
  const inset = findBorderInset(alignBox, node, styles, layout);
107
138
  return alignBox.width - inset * 2;
108
139
  }
140
+ /** Paint <svt-ansi> content: cells carry their own SGR styling. */
141
+ function paintAnsiContent(text, buffer, box, clip) {
142
+ const lines = parseAnsiText(text);
143
+ for (let row = 0; row < lines.length; row++) {
144
+ const y = box.y + row;
145
+ if (clip && (y < clip.y || y >= clip.y + clip.height))
146
+ continue;
147
+ for (let col = 0; col < lines[row].length; col++) {
148
+ const x = box.x + col;
149
+ if (clip && (x < clip.x || x >= clip.x + clip.width))
150
+ continue;
151
+ buffer.setCell(x, y, lines[row][col]);
152
+ }
153
+ }
154
+ }
109
155
  /** Find the border inset of the ancestor that owns alignBox. */
110
156
  function findBorderInset(alignBox, node, styles, layout) {
111
157
  let current = node.parent;
@@ -2,4 +2,11 @@ import { TermNode } from '../renderer/node.js';
2
2
  import { CellBuffer } from './buffer.js';
3
3
  import { ResolvedStyle } from '../css/compute.js';
4
4
  import { LayoutBox } from '../layout/engine.js';
5
- export declare function paint(root: TermNode, buffer: CellBuffer, styles?: Map<number, ResolvedStyle>, layout?: Map<number, LayoutBox>): void;
5
+ interface ClipRect {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+ export declare function paint(root: TermNode, buffer: CellBuffer, styles?: Map<number, ResolvedStyle>, layout?: Map<number, LayoutBox>, damageClip?: ClipRect): void;
12
+ export {};