@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,19 +1,54 @@
1
+ import { SvtRegionNode, childrenWithPseudos, hasBooleanAttribute } from '../renderer/node.js';
1
2
  import { renderBorder } from './border.js';
2
3
  import { paintTextContent } from './paint-text.js';
4
+ import { blendColor } from '../css/color.js';
5
+ import { stringWidth } from '../layout/unicode.js';
6
+ import { bumpPaintGeneration, paintGeneration } from './generation.js';
7
+ import { ensureImageLoading, paintImage } from './image.js';
8
+ import { renderScrollbar, renderHScrollbar } from './scrollbar.js';
9
+ import { dispatchEvent } from '../input/dispatch.js';
10
+ import { selectOptions, selectedIndex } from '../input/select.js';
3
11
  const DEFAULT_VISUALS = {
4
12
  fg: 'default', bg: 'default',
5
13
  bold: false, italic: false, underline: false, strikethrough: false, dim: false,
6
14
  };
7
15
  const NO_SCROLL = { x: 0, y: 0 };
8
- export function paint(root, buffer, styles, layout) {
9
- paintNode(root, buffer, styles, layout, DEFAULT_VISUALS, null, NO_SCROLL);
16
+ export function paint(root, buffer, styles, layout, damageClip) {
17
+ // Damage-clipped paints repaint a region, not the whole tree — nodes
18
+ // outside the damage keep their cached cursor positions.
19
+ if (!damageClip)
20
+ bumpPaintGeneration();
21
+ paintNode(root, buffer, styles, layout, DEFAULT_VISUALS, null, NO_SCROLL, damageClip);
10
22
  }
11
- function paintNode(node, buffer, styles, layout, inherited, clip, scroll) {
23
+ function paintNode(node, buffer, styles, layout, inherited, clip, scroll, damageClip) {
12
24
  if (node.nodeType === 'comment')
13
25
  return;
14
26
  const visuals = resolveVisuals(node, styles, inherited);
15
27
  const rawBox = layout?.get(node.id);
16
- const box = rawBox ? applyScroll(rawBox, scroll) : undefined;
28
+ let box = rawBox ? applyScroll(rawBox, scroll) : undefined;
29
+ // position: sticky (top): when scrolled past inside a clipping
30
+ // container, pin to the container top + offset. Descendants follow
31
+ // via an adjusted child scroll; applied before culling so a stuck
32
+ // element scrolled far past its flow position still paints.
33
+ let stickyDelta = 0;
34
+ if (box && clip && node.nodeType === 'element') {
35
+ const stickyStyle = styles?.get(node.id);
36
+ if (stickyStyle?.position === 'sticky' && stickyStyle.top !== null) {
37
+ const stuckY = Math.max(box.y, clip.y + (stickyStyle.top ?? 0));
38
+ stickyDelta = stuckY - box.y;
39
+ if (stickyDelta > 0)
40
+ box = { ...box, y: stuckY };
41
+ }
42
+ }
43
+ // Skip nodes entirely outside the damage region
44
+ if (damageClip && box && !boxesOverlap(box, damageClip))
45
+ return;
46
+ // Cull subtrees fully outside the active clip: every cell write is
47
+ // clipped anyway, so this can't change output — it skips the walk.
48
+ // (In-flow descendants sit inside their ancestor's box; positioned
49
+ // ones offset from their parent, so they leave with it.)
50
+ if (clip && box && box.width > 0 && box.height > 0 && !boxesOverlap(box, clip))
51
+ return;
17
52
  // Check display:none — element and all descendants are invisible and take no space
18
53
  const ownStyle = node.nodeType === 'element' ? styles?.get(node.id) : undefined;
19
54
  const parentStyle = node.parent ? styles?.get(node.parent.id) : undefined;
@@ -29,19 +64,49 @@ function paintNode(node, buffer, styles, layout, inherited, clip, scroll) {
29
64
  return;
30
65
  }
31
66
  if (node.nodeType === 'element' && box && !isHidden) {
32
- fillBackground(buffer, box, visuals, clip);
33
- if (ownStyle && ownStyle.borderStyle !== 'none') {
34
- renderBorder(buffer, box, ownStyle);
67
+ const hideEmptyCell = ownStyle?.emptyCells === 'hide'
68
+ && ownStyle.display === 'table-cell' && isEmptyCell(node);
69
+ if (!hideEmptyCell) {
70
+ fillBackground(buffer, box, visuals, clip, ownStyle);
71
+ if (ownStyle && ownStyle.borderStyle !== 'none') {
72
+ renderBorder(buffer, box, ownStyle);
73
+ }
74
+ }
75
+ if (node.tag === 'img') {
76
+ ensureImageLoading(node);
77
+ paintImage(node, buffer, box, clip);
78
+ return; // replaced element — no children render
35
79
  }
36
80
  if (node.tag === 'hr') {
37
81
  paintHorizontalRule(buffer, box, visuals, clip);
38
82
  return;
39
83
  }
84
+ if (node.tag === 'progress' || node.tag === 'meter') {
85
+ paintValueBar(node, buffer, box, visuals, clip);
86
+ return; // fallback content is not rendered, as in browsers
87
+ }
88
+ if (node.tag === 'select') {
89
+ paintSelect(node, buffer, box, visuals, clip);
90
+ return; // options render through the select's own label
91
+ }
40
92
  if (node.tag === 'li') {
41
93
  paintListMarker(node, buffer, box, visuals, clip);
42
94
  }
95
+ if (node.tag === 'summary') {
96
+ paintSummaryMarker(node, buffer, box, visuals, clip);
97
+ }
43
98
  if (node.tag === 'input' && box) {
44
- paintInput(node, buffer, box, visuals, clip);
99
+ const inputType = node.attributes.get('type');
100
+ if (inputType === 'checkbox' || inputType === 'radio') {
101
+ paintCheckable(node, buffer, box, visuals, clip);
102
+ }
103
+ else {
104
+ paintInput(node, buffer, box, visuals, clip);
105
+ }
106
+ }
107
+ if (node instanceof SvtRegionNode) {
108
+ paintRegion(node, buffer, box, clip);
109
+ return;
45
110
  }
46
111
  }
47
112
  // Determine clip and scroll for children
@@ -49,17 +114,113 @@ function paintNode(node, buffer, styles, layout, inherited, clip, scroll) {
49
114
  let childScroll = scroll;
50
115
  if (node.nodeType === 'element' && box) {
51
116
  const ownStyle = styles?.get(node.id);
52
- if (ownStyle && ownStyle.overflow !== 'visible') {
53
- childClip = intersectClip(clip, box);
117
+ if (node.tag === 'root') {
118
+ // Root clips to the terminal viewport (buffer bounds)
119
+ childClip = intersectClip(clip, { x: 0, y: 0, width: buffer.width, height: buffer.height });
120
+ }
121
+ else if (ownStyle && ownStyle.overflow !== 'visible') {
122
+ // Clip to the content box, inside any border — otherwise
123
+ // scrolled content paints over the border cells.
124
+ const inset = (ownStyle.borderStyle && ownStyle.borderStyle !== 'none') ? 1 : 0;
125
+ childClip = intersectClip(clip, {
126
+ x: box.x + inset, y: box.y + inset,
127
+ width: Math.max(0, box.width - inset * 2),
128
+ height: Math.max(0, box.height - inset * 2),
129
+ });
54
130
  }
55
131
  if (node.scrollTop !== 0 || node.scrollLeft !== 0) {
56
132
  childScroll = { x: scroll.x + node.scrollLeft, y: scroll.y + node.scrollTop };
57
133
  }
58
134
  }
59
- for (const child of node.children) {
60
- paintNode(child, buffer, styles, layout, visuals, childClip, childScroll);
135
+ // Descendants of a stuck element move with it
136
+ if (stickyDelta > 0)
137
+ childScroll = { x: childScroll.x, y: childScroll.y - stickyDelta };
138
+ // Sticky children paint after in-flow siblings so scrolled content
139
+ // doesn't overpaint a stuck header (positioned elements stack above).
140
+ const kids = childrenWithPseudos(node);
141
+ const hasSticky = kids.some(c => c.nodeType === 'element' && styles?.get(c.id)?.position === 'sticky');
142
+ const ordered = hasSticky
143
+ ? [...kids.filter(c => styles?.get(c.id)?.position !== 'sticky'),
144
+ ...kids.filter(c => styles?.get(c.id)?.position === 'sticky')]
145
+ : kids;
146
+ for (const child of ordered) {
147
+ paintNode(child, buffer, styles, layout, visuals, childClip, childScroll, damageClip);
148
+ }
149
+ // Render scrollbar overlays for scrollable containers
150
+ const now = Date.now();
151
+ const showVScroll = node.nodeType === 'element' && node.scrollbarVisibleUntil > now;
152
+ const showHScroll = node.nodeType === 'element' && node.hScrollbarVisibleUntil > now;
153
+ if (showVScroll || showHScroll) {
154
+ const nodeBox = layout?.get(node.id);
155
+ let contentHeight = 0;
156
+ let contentWidth = 0;
157
+ if (nodeBox && layout) {
158
+ // Scroll frames reuse the same layout map — walking a huge
159
+ // list's children every scrollbar frame would dominate paint.
160
+ const extent = cachedContentExtent(node, layout, nodeBox);
161
+ contentHeight = extent.height;
162
+ contentWidth = extent.width;
163
+ }
164
+ const visibleMs = 600;
165
+ const fadeMs = 400;
166
+ if (showVScroll) {
167
+ const remaining = node.scrollbarVisibleUntil - now;
168
+ const opacity = remaining > fadeMs ? 1 : remaining / fadeMs;
169
+ if (node.tag === 'root') {
170
+ renderScrollbar(buffer, 0, 0, buffer.width, buffer.height, contentHeight, node.scrollTop, opacity);
171
+ }
172
+ else if (box) {
173
+ renderScrollbar(buffer, box.x, box.y, box.width, box.height, contentHeight, node.scrollTop, opacity);
174
+ }
175
+ }
176
+ if (showHScroll && !showVScroll) {
177
+ const remaining = node.hScrollbarVisibleUntil - now;
178
+ const opacity = remaining > fadeMs ? 1 : remaining / fadeMs;
179
+ if (node.tag === 'root') {
180
+ renderHScrollbar(buffer, 0, 0, buffer.width, buffer.height, contentWidth, node.scrollLeft, opacity);
181
+ }
182
+ else if (box) {
183
+ renderHScrollbar(buffer, box.x, box.y, box.width, box.height, contentWidth, node.scrollLeft, opacity);
184
+ }
185
+ }
61
186
  }
62
187
  }
188
+ /** Content extents per (layout map, node) — valid as long as the layout is. */
189
+ const extentCache = new WeakMap();
190
+ function cachedContentExtent(node, layout, nodeBox) {
191
+ let perNode = extentCache.get(layout);
192
+ if (!perNode) {
193
+ perNode = new Map();
194
+ extentCache.set(layout, perNode);
195
+ }
196
+ const cached = perNode.get(node.id);
197
+ if (cached)
198
+ return cached;
199
+ let height = 0;
200
+ let width = 0;
201
+ const walk = (n) => {
202
+ const cBox = layout.get(n.id);
203
+ if (cBox) {
204
+ height = Math.max(height, cBox.y - nodeBox.y + cBox.height);
205
+ width = Math.max(width, cBox.x - nodeBox.x + cBox.width);
206
+ }
207
+ for (const child of n.children)
208
+ walk(child);
209
+ };
210
+ for (const child of node.children)
211
+ walk(child);
212
+ const extent = { width, height };
213
+ perNode.set(node.id, extent);
214
+ return extent;
215
+ }
216
+ /** A table cell is empty (for empty-cells: hide) if it has no element children and no visible text. */
217
+ function isEmptyCell(node) {
218
+ return node.children.every(child => child.nodeType === 'text' && (child.textContent ?? '').trim() === '');
219
+ }
220
+ function boxesOverlap(a, b) {
221
+ return a.x < b.x + b.width && a.x + a.width > b.x
222
+ && a.y < b.y + b.height && a.y + a.height > b.y;
223
+ }
63
224
  function applyScroll(box, scroll) {
64
225
  if (scroll.x === 0 && scroll.y === 0)
65
226
  return box;
@@ -70,17 +231,62 @@ function paintText(node, buffer, box, visuals, clip, parentStyle, parentBox, sty
70
231
  return;
71
232
  paintTextContent(node, buffer, box, visuals, styles, layout, clip);
72
233
  }
73
- function fillBackground(buffer, box, visuals, clip) {
234
+ function fillBackground(buffer, box, visuals, clip, style) {
74
235
  if (visuals.bg === 'default')
75
236
  return;
76
- for (let row = box.y; row < box.y + box.height; row++) {
77
- for (let col = box.x; col < box.x + box.width; col++) {
237
+ // For inner-facing block-character borders, the border cells' bg should
238
+ // stop AT the stroke, not extend past it. Skip the entire border-cell ring
239
+ // so the stroke is the visible outer edge of the colored area. The stroke
240
+ // glyph still paints its 1/8 or 1/2 cell mark on a transparent cell.
241
+ const skipBorderRing = style ? isInnerFacingBlockBorder(style) : false;
242
+ // For borders with blank corners (no corner glyph), the corner cells stay
243
+ // transparent so bg doesn't leak through the gap.
244
+ const skipCorners = style ? hasBlankCorners(style) : false;
245
+ const left = box.x;
246
+ const right = box.x + box.width - 1;
247
+ const top = box.y;
248
+ const bottom = box.y + box.height - 1;
249
+ const bgHasAlpha = visuals.bg.startsWith('#') && visuals.bg.length === 9;
250
+ for (let row = top; row <= bottom; row++) {
251
+ for (let col = left; col <= right; col++) {
78
252
  if (clip && !inClip(col, row, clip))
79
253
  continue;
80
- buffer.setCell(col, row, { bg: visuals.bg });
254
+ if (skipBorderRing && isBorderCell(col, row, left, right, top, bottom))
255
+ continue;
256
+ if (skipCorners && isCorner(col, row, left, right, top, bottom))
257
+ continue;
258
+ const bg = bgHasAlpha
259
+ ? blendColor(buffer.getCell(col, row)?.bg ?? 'default', visuals.bg)
260
+ : visuals.bg;
261
+ // An opaque fill covers what's beneath — clear stale glyphs
262
+ // too, so overlapping paints (sticky, absolute) don't show
263
+ // earlier content through the background. The element's own
264
+ // text repaints after this fill.
265
+ buffer.setCell(col, row, { bg, char: ' ' });
81
266
  }
82
267
  }
83
268
  }
269
+ function isInnerFacingBlockBorder(style) {
270
+ const bs = style.borderStyle;
271
+ return bs === 'eighth-cell-inner' || bs === 'half-cell-inner';
272
+ }
273
+ function hasBlankCorners(style) {
274
+ if (style.borderCorner !== 'none')
275
+ return false;
276
+ // Only eighth-cell-inner has blank corners in the default configuration:
277
+ // the stroke sits at the inner edge, so the corner cell has nothing to
278
+ // draw and the bg should not leak through.
279
+ // eighth-cell-outer extends top/bottom strokes through corners by default
280
+ // (see renderBlockBorder), so the bg should fill the corner cells too.
281
+ // half-cell-* use quadrant/L corner glyphs.
282
+ return style.borderStyle === 'eighth-cell-inner';
283
+ }
284
+ function isBorderCell(col, row, left, right, top, bottom) {
285
+ return col === left || col === right || row === top || row === bottom;
286
+ }
287
+ function isCorner(col, row, left, right, top, bottom) {
288
+ return (col === left || col === right) && (row === top || row === bottom);
289
+ }
84
290
  function paintListMarker(node, buffer, box, visuals, clip) {
85
291
  const parent = node.parent;
86
292
  if (!parent)
@@ -94,13 +300,55 @@ function paintListMarker(node, buffer, box, visuals, clip) {
94
300
  else {
95
301
  marker = '• ';
96
302
  }
303
+ // Paint marker before the content (offset left by marker width)
304
+ const markerX = box.x - marker.length;
97
305
  for (let i = 0; i < marker.length; i++) {
98
- const cx = box.x + i;
306
+ const cx = markerX + i;
307
+ if (cx < 0)
308
+ continue;
99
309
  if (clip && !inClip(cx, box.y, clip))
100
310
  continue;
101
311
  buffer.setCell(cx, box.y, { char: marker[i], fg: visuals.fg, dim: visuals.dim });
102
312
  }
103
313
  }
314
+ /** The selected option's label with a cycle indicator: "label ▾". */
315
+ function paintSelect(node, buffer, box, visuals, clip) {
316
+ const option = selectOptions(node)[selectedIndex(node)];
317
+ const text = `${option ? option.textContent.trim() : ''} ▾`;
318
+ for (let i = 0; i < Math.min(text.length, box.width); i++) {
319
+ const col = box.x + i;
320
+ if (clip && !inClip(col, box.y, clip))
321
+ continue;
322
+ buffer.setCell(col, box.y, {
323
+ char: text[i], fg: visuals.fg, bg: visuals.bg,
324
+ bold: visuals.bold, dim: visuals.dim,
325
+ });
326
+ }
327
+ }
328
+ /** Disclosure triangle in the summary's marker padding: ▶ closed, ▼ open. */
329
+ function paintSummaryMarker(node, buffer, box, visuals, clip) {
330
+ const details = node.parent;
331
+ const open = details?.tag === 'details' && hasBooleanAttribute(details, 'open');
332
+ if (clip && !inClip(box.x, box.y, clip))
333
+ return;
334
+ buffer.setCell(box.x, box.y, { char: open ? '▼' : '▶', fg: visuals.fg });
335
+ }
336
+ /** Checkbox → [x]/[ ], radio → (•)/( ), coloured by the element's visuals. */
337
+ function paintCheckable(node, buffer, box, visuals, clip) {
338
+ const checked = hasBooleanAttribute(node, 'checked');
339
+ const glyphs = node.attributes.get('type') === 'radio'
340
+ ? (checked ? '(•)' : '( )')
341
+ : (checked ? '[x]' : '[ ]');
342
+ for (let i = 0; i < glyphs.length; i++) {
343
+ const col = box.x + i;
344
+ if (clip && !inClip(col, box.y, clip))
345
+ continue;
346
+ buffer.setCell(col, box.y, {
347
+ char: glyphs[i], fg: visuals.fg, bg: visuals.bg,
348
+ bold: visuals.bold, dim: visuals.dim,
349
+ });
350
+ }
351
+ }
104
352
  function paintInput(node, buffer, box, visuals, clip) {
105
353
  const value = node.attributes.get('value') ?? '';
106
354
  const isFocused = node.attributes.has('data-focused');
@@ -160,19 +408,18 @@ function paintInput(node, buffer, box, visuals, clip) {
160
408
  buffer.setCell(cx, contentY, { char: '…', fg: visuals.fg, dim: true });
161
409
  }
162
410
  }
163
- // Cursor (inverted colors)
411
+ // Publish the text-cursor screen position so the post-paint emitter
412
+ // can drive the real terminal cursor. No cell is painted — the real
413
+ // cursor brings its own blink and shape.
164
414
  if (isFocused) {
165
- const cursorScreenX = contentX + (cursor - scrollOffset);
166
- if (cursorScreenX >= contentX && cursorScreenX <= contentX + contentW) {
167
- const cursorChar = cursor < value.length ? value[cursor] : ' ';
168
- if (!clip || inClip(cursorScreenX, contentY, clip)) {
169
- buffer.setCell(cursorScreenX, contentY, {
170
- char: cursorChar,
171
- fg: visuals.bg !== 'default' ? visuals.bg : 'black',
172
- bg: visuals.fg !== 'default' ? visuals.fg : 'white',
173
- });
174
- }
175
- }
415
+ // Cursor cell offset = cell width of the text before it (wide
416
+ // glyphs take two columns), not its code-unit index.
417
+ const cursorCells = stringWidth(value.substring(0, cursor));
418
+ const cursorScreenX = contentX + (cursorCells - scrollOffset);
419
+ const inViewport = cursorScreenX >= contentX
420
+ && cursorScreenX <= contentX + contentW
421
+ && (!clip || inClip(cursorScreenX, contentY, clip));
422
+ node.cache.cursorScreen = { x: cursorScreenX, y: contentY, inViewport, generation: paintGeneration() };
176
423
  }
177
424
  }
178
425
  function resolvePadVal(v) {
@@ -180,6 +427,42 @@ function resolvePadVal(v) {
180
427
  return v;
181
428
  return 0;
182
429
  }
430
+ /** Left-partial block glyphs by eighths (index 1–7). */
431
+ const PARTIAL_BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
432
+ /** <progress>/<meter>: filled blocks for value, light shade for the track. */
433
+ function paintValueBar(node, buffer, box, visuals, clip) {
434
+ const ratio = valueBarRatio(node);
435
+ const fillCells = ratio * box.width;
436
+ let fullCells = Math.floor(fillCells);
437
+ let eighths = Math.round((fillCells - fullCells) * 8);
438
+ if (eighths === 8) {
439
+ fullCells++;
440
+ eighths = 0;
441
+ }
442
+ for (let r = 0; r < box.height; r++) {
443
+ for (let c = 0; c < box.width; c++) {
444
+ const col = box.x + c;
445
+ const row = box.y + r;
446
+ if (clip && !inClip(col, row, clip))
447
+ continue;
448
+ const char = c < fullCells ? '█'
449
+ : (c === fullCells && eighths > 0) ? PARTIAL_BLOCKS[eighths]
450
+ : '░';
451
+ buffer.setCell(col, row, { char, fg: visuals.fg, bg: visuals.bg });
452
+ }
453
+ }
454
+ }
455
+ /** Fraction filled, from value/max (progress) or value within min/max (meter). */
456
+ function valueBarRatio(node) {
457
+ const value = parseFloat(node.attributes.get('value') ?? '');
458
+ if (isNaN(value))
459
+ return 0; // indeterminate
460
+ const min = node.tag === 'meter' ? (parseFloat(node.attributes.get('min') ?? '') || 0) : 0;
461
+ const max = parseFloat(node.attributes.get('max') ?? '') || 1;
462
+ if (max <= min)
463
+ return 0;
464
+ return Math.max(0, Math.min(1, (value - min) / (max - min)));
465
+ }
183
466
  function paintHorizontalRule(buffer, box, visuals, clip) {
184
467
  for (let col = box.x; col < box.x + box.width; col++) {
185
468
  if (clip && !inClip(col, box.y, clip))
@@ -187,6 +470,31 @@ function paintHorizontalRule(buffer, box, visuals, clip) {
187
470
  buffer.setCell(col, box.y, { char: '─', fg: visuals.fg, dim: true });
188
471
  }
189
472
  }
473
+ /**
474
+ * Paint an svt-region. Fires `resize` if the allocated cell dimensions
475
+ * have changed since the last paint, then calls the consumer-registered
476
+ * cell source for each cell of the region's box (local coordinates) and
477
+ * writes the returned cell into the buffer at the absolute position.
478
+ */
479
+ function paintRegion(node, buffer, box, clip) {
480
+ node.notifyAllocatedSize(box.width, box.height, (cols, rows) => {
481
+ dispatchEvent(node, 'resize', { cols, rows });
482
+ });
483
+ node.lastBoxX = box.x;
484
+ node.lastBoxY = box.y;
485
+ const cellSource = node.getCellSource();
486
+ if (!cellSource)
487
+ return;
488
+ for (let r = 0; r < box.height; r++) {
489
+ for (let c = 0; c < box.width; c++) {
490
+ const x = box.x + c;
491
+ const y = box.y + r;
492
+ if (clip && !inClip(x, y, clip))
493
+ continue;
494
+ buffer.setCell(x, y, cellSource(c, r));
495
+ }
496
+ }
497
+ }
190
498
  function inClip(col, row, clip) {
191
499
  return col >= clip.x && col < clip.x + clip.width
192
500
  && row >= clip.y && row < clip.y + clip.height;
@@ -207,9 +515,10 @@ function resolveVisuals(node, styles, inherited) {
207
515
  if (!own)
208
516
  return inherited;
209
517
  const hyperlink = node.tag === 'a' ? node.attributes.get('href') : inherited.hyperlink;
518
+ const opacity = own.opacity;
210
519
  return {
211
- fg: own.fg !== 'default' ? own.fg : inherited.fg,
212
- bg: own.bg !== 'default' ? own.bg : inherited.bg,
520
+ fg: applyOpacity(own.fg !== 'default' ? own.fg : inherited.fg, opacity),
521
+ bg: applyOpacity(own.bg !== 'default' ? own.bg : inherited.bg, opacity),
213
522
  bold: own.bold || inherited.bold,
214
523
  italic: own.italic || inherited.italic,
215
524
  underline: own.underline || inherited.underline,
@@ -218,3 +527,21 @@ function resolveVisuals(node, styles, inherited) {
218
527
  hyperlink,
219
528
  };
220
529
  }
530
+ /** Fold a numeric opacity into the colour's alpha channel. */
531
+ function applyOpacity(color, opacity) {
532
+ if (opacity >= 1 || color === 'default')
533
+ return color;
534
+ const hex = color.startsWith('#') ? color : nominalHex(color);
535
+ if (!hex)
536
+ return color;
537
+ const existing = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1;
538
+ const alpha = Math.round(existing * opacity * 255);
539
+ return hex.slice(0, 7) + alpha.toString(16).padStart(2, '0');
540
+ }
541
+ const NOMINAL_HEX = {
542
+ black: '#000000', red: '#cd0000', green: '#00cd00', yellow: '#cdcd00',
543
+ blue: '#0000ee', magenta: '#cd00cd', cyan: '#00cdcd', white: '#e5e5e5',
544
+ };
545
+ function nominalHex(name) {
546
+ return NOMINAL_HEX[name] ?? null;
547
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Minimal PNG decoder for <img>: 8-bit RGB/RGBA/greyscale/palette, no
3
+ * interlace, inflate via node:zlib — no dependencies. Half-block
4
+ * rendering needs pixels, not fidelity; anything fancier should be
5
+ * converted before shipping to a terminal anyway.
6
+ */
7
+ export interface DecodedImage {
8
+ width: number;
9
+ height: number;
10
+ /** Row-major RGBA, 4 bytes per pixel. */
11
+ rgba: Uint8Array;
12
+ }
13
+ export declare function decodePng(data: Uint8Array): DecodedImage;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Minimal PNG decoder for <img>: 8-bit RGB/RGBA/greyscale/palette, no
3
+ * interlace, inflate via node:zlib — no dependencies. Half-block
4
+ * rendering needs pixels, not fidelity; anything fancier should be
5
+ * converted before shipping to a terminal anyway.
6
+ */
7
+ import { inflateSync } from 'node:zlib';
8
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
9
+ /** Bytes per pixel for the colour types we support. */
10
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 6: 4 };
11
+ export function decodePng(data) {
12
+ for (let i = 0; i < SIGNATURE.length; i++) {
13
+ if (data[i] !== SIGNATURE[i])
14
+ throw new Error('Not a PNG file');
15
+ }
16
+ let width = 0;
17
+ let height = 0;
18
+ let colourType = -1;
19
+ let palette = null;
20
+ const idat = [];
21
+ let offset = 8;
22
+ while (offset + 8 <= data.length) {
23
+ const length = readU32(data, offset);
24
+ const type = String.fromCharCode(...data.slice(offset + 4, offset + 8));
25
+ const body = data.slice(offset + 8, offset + 8 + length);
26
+ if (type === 'IHDR') {
27
+ width = readU32(body, 0);
28
+ height = readU32(body, 4);
29
+ const bitDepth = body[8];
30
+ colourType = body[9];
31
+ if (bitDepth !== 8)
32
+ throw new Error(`PNG bit depth ${bitDepth} not supported (8 only)`);
33
+ if (!(colourType in CHANNELS))
34
+ throw new Error(`PNG colour type ${colourType} not supported`);
35
+ if (body[12] !== 0)
36
+ throw new Error('Interlaced PNG not supported');
37
+ }
38
+ else if (type === 'PLTE') {
39
+ palette = body;
40
+ }
41
+ else if (type === 'IDAT') {
42
+ idat.push(body);
43
+ }
44
+ else if (type === 'IEND') {
45
+ break;
46
+ }
47
+ offset += 12 + length;
48
+ }
49
+ if (width === 0 || height === 0 || idat.length === 0)
50
+ throw new Error('Truncated PNG');
51
+ const channels = CHANNELS[colourType];
52
+ const raw = inflateSync(concat(idat));
53
+ const stride = width * channels;
54
+ const unfiltered = unfilter(raw, width, height, channels);
55
+ const rgba = new Uint8Array(width * height * 4);
56
+ for (let y = 0; y < height; y++) {
57
+ for (let x = 0; x < width; x++) {
58
+ const src = y * stride + x * channels;
59
+ const dst = (y * width + x) * 4;
60
+ switch (colourType) {
61
+ case 6:
62
+ rgba.set(unfiltered.slice(src, src + 4), dst);
63
+ break;
64
+ case 2:
65
+ rgba.set(unfiltered.slice(src, src + 3), dst);
66
+ rgba[dst + 3] = 255;
67
+ break;
68
+ case 0: {
69
+ const grey = unfiltered[src];
70
+ rgba[dst] = grey;
71
+ rgba[dst + 1] = grey;
72
+ rgba[dst + 2] = grey;
73
+ rgba[dst + 3] = 255;
74
+ break;
75
+ }
76
+ case 3: {
77
+ const index = unfiltered[src] * 3;
78
+ rgba[dst] = palette?.[index] ?? 0;
79
+ rgba[dst + 1] = palette?.[index + 1] ?? 0;
80
+ rgba[dst + 2] = palette?.[index + 2] ?? 0;
81
+ rgba[dst + 3] = 255;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return { width, height, rgba };
88
+ }
89
+ /** Undo per-row PNG filters (types 0–4). */
90
+ function unfilter(raw, width, height, channels) {
91
+ const stride = width * channels;
92
+ const out = new Uint8Array(stride * height);
93
+ for (let y = 0; y < height; y++) {
94
+ const filter = raw[y * (stride + 1)];
95
+ const rowIn = raw.slice(y * (stride + 1) + 1, (y + 1) * (stride + 1));
96
+ const rowOut = out.subarray(y * stride, (y + 1) * stride);
97
+ const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null;
98
+ for (let i = 0; i < stride; i++) {
99
+ const left = i >= channels ? rowOut[i - channels] : 0;
100
+ const up = prev ? prev[i] : 0;
101
+ const upLeft = prev && i >= channels ? prev[i - channels] : 0;
102
+ let value = rowIn[i];
103
+ switch (filter) {
104
+ case 1:
105
+ value += left;
106
+ break;
107
+ case 2:
108
+ value += up;
109
+ break;
110
+ case 3:
111
+ value += Math.floor((left + up) / 2);
112
+ break;
113
+ case 4:
114
+ value += paeth(left, up, upLeft);
115
+ break;
116
+ }
117
+ rowOut[i] = value & 0xff;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ function paeth(a, b, c) {
123
+ const p = a + b - c;
124
+ const pa = Math.abs(p - a);
125
+ const pb = Math.abs(p - b);
126
+ const pc = Math.abs(p - c);
127
+ if (pa <= pb && pa <= pc)
128
+ return a;
129
+ if (pb <= pc)
130
+ return b;
131
+ return c;
132
+ }
133
+ function readU32(data, offset) {
134
+ return (data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]) >>> 0;
135
+ }
136
+ function concat(parts) {
137
+ const total = parts.reduce((sum, p) => sum + p.length, 0);
138
+ const out = new Uint8Array(total);
139
+ let offset = 0;
140
+ for (const part of parts) {
141
+ out.set(part, offset);
142
+ offset += part.length;
143
+ }
144
+ return out;
145
+ }
@@ -1,3 +1,9 @@
1
1
  import { CellBuffer } from './buffer.js';
2
- import { LayoutBox } from '../layout/engine.js';
3
- export declare function renderScrollbar(buffer: CellBuffer, box: LayoutBox, contentHeight: number, scrollTop: number): void;
2
+ /**
3
+ * Render a vertical scrollbar overlay on the rightmost column.
4
+ */
5
+ export declare function renderScrollbar(buffer: CellBuffer, viewportX: number, viewportY: number, viewportWidth: number, viewportHeight: number, contentHeight: number, scrollTop: number, opacity: number): void;
6
+ /**
7
+ * Render a horizontal scrollbar overlay on the bottom row.
8
+ */
9
+ export declare function renderHScrollbar(buffer: CellBuffer, viewportX: number, viewportY: number, viewportWidth: number, viewportHeight: number, contentWidth: number, scrollLeft: number, opacity: number): void;