sygnal 7.0.0 → 7.2.0-preview.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 (343) hide show
  1. package/CHANGELOG.md +632 -0
  2. package/dist/agent-ui.cjs.js +592 -0
  3. package/dist/agent-ui.cjs.js.map +1 -0
  4. package/dist/agent-ui.esm.js +582 -0
  5. package/dist/agent-ui.esm.js.map +1 -0
  6. package/dist/api.cjs.js +63 -0
  7. package/dist/api.cjs.js.map +1 -0
  8. package/dist/api.esm.js +2 -0
  9. package/dist/api.esm.js.map +1 -0
  10. package/dist/astro/index.cjs.js +968 -4
  11. package/dist/astro/index.cjs.js.map +1 -1
  12. package/dist/astro/index.mjs +968 -4
  13. package/dist/astro/index.mjs.map +1 -1
  14. package/dist/astro/server.cjs.js +73 -45
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +73 -45
  17. package/dist/astro/server.mjs.map +1 -1
  18. package/dist/canvas.cjs.js +2919 -0
  19. package/dist/canvas.cjs.js.map +1 -0
  20. package/dist/canvas.esm.js +2866 -0
  21. package/dist/canvas.esm.js.map +1 -0
  22. package/dist/devtools.cjs.js +38 -6
  23. package/dist/devtools.cjs.js.map +1 -1
  24. package/dist/devtools.esm.js +38 -6
  25. package/dist/devtools.esm.js.map +1 -1
  26. package/dist/diagnostics.cjs.js +224 -20
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +224 -20
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/accessibility.md +1 -1
  31. package/dist/guide/ai-compose.md +150 -0
  32. package/dist/guide/behaviors.md +2 -1
  33. package/dist/guide/collaborative-state.md +265 -0
  34. package/dist/guide/element-commands.md +36 -2
  35. package/dist/guide/forms-reference.md +5 -5
  36. package/dist/guide/forms.md +3 -2
  37. package/dist/guide/http.md +28 -1
  38. package/dist/guide/islands.md +240 -0
  39. package/dist/guide/recipes/code-editor.md +1 -1
  40. package/dist/guide/renderers/agent-ui.md +138 -0
  41. package/dist/guide/renderers/custom-targets.md +126 -0
  42. package/dist/guide/renderers/overview.md +120 -0
  43. package/dist/guide/renderers/react-native.md +128 -0
  44. package/dist/guide/renderers/terminal.md +306 -0
  45. package/dist/guide/renderers/three.md +332 -0
  46. package/dist/guide/renderers/write-once.md +137 -0
  47. package/dist/guide/resources.md +2 -0
  48. package/dist/guide/sandboxed-components.md +223 -0
  49. package/dist/guide/timers.md +32 -5
  50. package/dist/guide/typed-apis.md +442 -0
  51. package/dist/guide/ui/accordion.md +11 -0
  52. package/dist/guide/ui/combobox.md +112 -8
  53. package/dist/guide/ui/dialog.md +96 -20
  54. package/dist/guide/ui/menu.md +57 -5
  55. package/dist/guide/ui/overview.md +8 -4
  56. package/dist/guide/ui/popover.md +69 -9
  57. package/dist/guide/ui/select.md +54 -4
  58. package/dist/guide/ui/tabs.md +1 -1
  59. package/dist/guide/ui/tooltip.md +7 -5
  60. package/dist/guide/virtual-collections.md +3 -0
  61. package/dist/guide/widgets.md +90 -1
  62. package/dist/host.cjs.js +2181 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2146 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +3948 -586
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +584 -44
  69. package/dist/index.esm.js +3918 -586
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/primitives.cjs.js +143 -0
  72. package/dist/primitives.cjs.js.map +1 -0
  73. package/dist/primitives.esm.js +133 -0
  74. package/dist/primitives.esm.js.map +1 -0
  75. package/dist/react-native.cjs.js +248 -0
  76. package/dist/react-native.cjs.js.map +1 -0
  77. package/dist/react-native.esm.js +244 -0
  78. package/dist/react-native.esm.js.map +1 -0
  79. package/dist/replay.cjs.js +39 -0
  80. package/dist/replay.cjs.js.map +1 -0
  81. package/dist/replay.esm.js +2 -0
  82. package/dist/replay.esm.js.map +1 -0
  83. package/dist/sandbox-build.cjs.js +140 -0
  84. package/dist/sandbox-build.cjs.js.map +1 -0
  85. package/dist/sandbox-build.esm.js +137 -0
  86. package/dist/sandbox-build.esm.js.map +1 -0
  87. package/dist/sandbox-kernel.esm.js +212 -0
  88. package/dist/sandbox-kernel.esm.js.map +1 -0
  89. package/dist/sandbox-worker.cjs.js +857 -0
  90. package/dist/sandbox-worker.cjs.js.map +1 -0
  91. package/dist/sandbox-worker.esm.js +854 -0
  92. package/dist/sandbox-worker.esm.js.map +1 -0
  93. package/dist/sandbox.cjs.js +1425 -0
  94. package/dist/sandbox.cjs.js.map +1 -0
  95. package/dist/sandbox.esm.js +1406 -0
  96. package/dist/sandbox.esm.js.map +1 -0
  97. package/dist/sygnal.min.js +1 -1
  98. package/dist/sygnal.min.js.map +1 -1
  99. package/dist/terminal.cjs.js +1314 -0
  100. package/dist/terminal.cjs.js.map +1 -0
  101. package/dist/terminal.esm.js +1304 -0
  102. package/dist/terminal.esm.js.map +1 -0
  103. package/dist/three.cjs.js +1111 -0
  104. package/dist/three.cjs.js.map +1 -0
  105. package/dist/three.esm.js +1079 -0
  106. package/dist/three.esm.js.map +1 -0
  107. package/dist/ui-combobox.cjs.js +256 -18
  108. package/dist/ui-combobox.cjs.js.map +1 -1
  109. package/dist/ui-combobox.esm.js +256 -18
  110. package/dist/ui-combobox.esm.js.map +1 -1
  111. package/dist/ui-menu.cjs.js +150 -16
  112. package/dist/ui-menu.cjs.js.map +1 -1
  113. package/dist/ui-menu.esm.js +151 -17
  114. package/dist/ui-menu.esm.js.map +1 -1
  115. package/dist/ui-select.cjs.js +95 -9
  116. package/dist/ui-select.cjs.js.map +1 -1
  117. package/dist/ui-select.esm.js +95 -9
  118. package/dist/ui-select.esm.js.map +1 -1
  119. package/dist/ui.cjs.js +679 -173
  120. package/dist/ui.cjs.js.map +1 -1
  121. package/dist/ui.esm.js +679 -175
  122. package/dist/ui.esm.js.map +1 -1
  123. package/dist/vike/config/package.json +1 -1
  124. package/dist/vite/plugin.cjs.js +968 -4
  125. package/dist/vite/plugin.cjs.js.map +1 -1
  126. package/dist/vite/plugin.mjs +968 -4
  127. package/dist/vite/plugin.mjs.map +1 -1
  128. package/dist/worker-page.cjs.js +669 -0
  129. package/dist/worker-page.cjs.js.map +1 -0
  130. package/dist/worker-page.esm.js +665 -0
  131. package/dist/worker-page.esm.js.map +1 -0
  132. package/dist/worker.cjs.js +377 -0
  133. package/dist/worker.cjs.js.map +1 -0
  134. package/dist/worker.esm.js +374 -0
  135. package/dist/worker.esm.js.map +1 -0
  136. package/dist/yjs-testing.cjs.js +98 -0
  137. package/dist/yjs-testing.cjs.js.map +1 -0
  138. package/dist/yjs-testing.esm.js +77 -0
  139. package/dist/yjs-testing.esm.js.map +1 -0
  140. package/dist/yjs.cjs.js +923 -0
  141. package/dist/yjs.cjs.js.map +1 -0
  142. package/dist/yjs.esm.js +900 -0
  143. package/dist/yjs.esm.js.map +1 -0
  144. package/dist/zag.cjs.js +11 -1
  145. package/dist/zag.cjs.js.map +1 -1
  146. package/dist/zag.esm.js +11 -1
  147. package/dist/zag.esm.js.map +1 -1
  148. package/llms/ai.md +65 -0
  149. package/llms/collaboration.md +5 -0
  150. package/llms/graphics.md +10 -0
  151. package/llms/renderers.md +7 -0
  152. package/llms/replay.md +5 -0
  153. package/llms/sandbox.md +5 -0
  154. package/llms/typed-apis.md +5 -0
  155. package/llms/ui.md +10 -0
  156. package/llms-full.txt +444 -0
  157. package/llms.txt +37 -35
  158. package/package.json +139 -3
  159. package/src/agent-ui/mcp.ts +152 -0
  160. package/src/agent-ui/snapshot.ts +212 -0
  161. package/src/agent-ui.d.ts +106 -0
  162. package/src/agent-ui.ts +249 -0
  163. package/src/ai.d.ts +95 -2
  164. package/src/api.d.ts +303 -0
  165. package/src/api.ts +9 -0
  166. package/src/canvas/Canvas.ts +585 -0
  167. package/src/canvas/backend.ts +627 -0
  168. package/src/canvas/geometry.ts +187 -0
  169. package/src/canvas/gesture.ts +23 -0
  170. package/src/canvas/image.ts +197 -0
  171. package/src/canvas/kit.ts +130 -0
  172. package/src/canvas/paint.ts +260 -0
  173. package/src/canvas/pointer.ts +25 -0
  174. package/src/canvas/snap.ts +68 -0
  175. package/src/canvas/svg.ts +99 -0
  176. package/src/canvas.d.ts +373 -0
  177. package/src/canvas.ts +46 -0
  178. package/src/core/actions.ts +14 -3
  179. package/src/core/cell.ts +12 -4
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +1 -1
  183. package/src/core/hosts/switchable.ts +26 -8
  184. package/src/core/instance.ts +98 -45
  185. package/src/core/markers/portal.ts +4 -0
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/core/statics.ts +5 -5
  189. package/src/cycle/dom/EventDelegator.ts +37 -33
  190. package/src/cycle/dom/MainDOMSource.ts +16 -14
  191. package/src/cycle/dom/enrichEventStream.ts +13 -9
  192. package/src/cycle/dom/fromEvent.ts +6 -10
  193. package/src/cycle/dom/index.ts +11 -7
  194. package/src/cycle/dom/isolate.ts +13 -6
  195. package/src/cycle/dom/makeDOMDriver.ts +6 -2
  196. package/src/cycle/dom/utils.ts +9 -2
  197. package/src/cycle/run/adapt.ts +4 -12
  198. package/src/devtools.d.ts +9 -0
  199. package/src/devtools.ts +2 -1
  200. package/src/extra/ai/compose/catalog.ts +2 -1
  201. package/src/extra/ai/compose/decisions.ts +1 -2
  202. package/src/extra/ai/compose/generated.ts +234 -0
  203. package/src/extra/ai/compose/index.ts +6 -2
  204. package/src/extra/ai/compose/layout.ts +3 -1
  205. package/src/extra/ai/compose/llm.ts +7 -3
  206. package/src/extra/ai/compose/types.ts +2 -2
  207. package/src/extra/ai/transports/shared.ts +3 -1
  208. package/src/extra/api/contract.ts +485 -0
  209. package/src/extra/api/index.ts +26 -0
  210. package/src/extra/behaviors.ts +3 -3
  211. package/src/extra/controlled.ts +44 -0
  212. package/src/extra/copyAsTest.ts +22 -0
  213. package/src/extra/devtools.ts +5 -3
  214. package/src/extra/devtoolsActions.ts +8 -0
  215. package/src/extra/devtoolsNext.ts +1 -1
  216. package/src/extra/diagnostics/checks/actionLog.ts +8 -3
  217. package/src/extra/diagnostics/checks/behaviors.ts +25 -0
  218. package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
  219. package/src/extra/diagnostics/checks/index.ts +1 -1
  220. package/src/extra/diagnostics/checks/next.ts +78 -4
  221. package/src/extra/diagnostics/checks/props.ts +6 -1
  222. package/src/extra/diagnostics/checks/replies.ts +11 -7
  223. package/src/extra/diagnostics/checks/timers.ts +8 -2
  224. package/src/extra/diagnostics/checks/virtual.ts +11 -0
  225. package/src/extra/diagnostics/codes.ts +51 -1
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +4 -1
  228. package/src/extra/fetchEventSource.ts +6 -2
  229. package/src/extra/field.ts +29 -0
  230. package/src/extra/form.ts +42 -16
  231. package/src/extra/frame.ts +54 -0
  232. package/src/extra/keymap.ts +41 -0
  233. package/src/extra/pager.ts +2 -0
  234. package/src/extra/persist.ts +2 -1
  235. package/src/extra/pointer.ts +262 -0
  236. package/src/extra/processForm.ts +34 -1
  237. package/src/extra/replay/address.ts +49 -0
  238. package/src/extra/replay/entropy.ts +99 -0
  239. package/src/extra/replay/format.ts +263 -0
  240. package/src/extra/replay/index.ts +8 -0
  241. package/src/extra/replay/inert.ts +22 -0
  242. package/src/extra/replay/recorder.ts +247 -0
  243. package/src/extra/replay/replayer.ts +234 -0
  244. package/src/extra/replay/report.ts +126 -0
  245. package/src/extra/replay/schema.ts +52 -0
  246. package/src/extra/replay/version.ts +3 -0
  247. package/src/extra/router.ts +7 -3
  248. package/src/extra/selection.ts +2 -0
  249. package/src/extra/socketDriver.ts +51 -6
  250. package/src/extra/ssr.ts +128 -42
  251. package/src/extra/testing.ts +295 -39
  252. package/src/extra/timers.ts +96 -7
  253. package/src/extra/undo.ts +2 -0
  254. package/src/extra/virtual.ts +25 -2
  255. package/src/extra/widget.ts +106 -17
  256. package/src/host/actions.ts +383 -0
  257. package/src/host/delegation.ts +101 -0
  258. package/src/host/dom.ts +553 -0
  259. package/src/host/driver.ts +185 -0
  260. package/src/host/selector.ts +119 -0
  261. package/src/host/style.ts +51 -0
  262. package/src/host.d.ts +298 -0
  263. package/src/host.ts +46 -0
  264. package/src/index.d.ts +302 -38
  265. package/src/index.ts +15 -3
  266. package/src/pragma/blocks.ts +211 -0
  267. package/src/pragma/index.ts +44 -10
  268. package/src/primitives.d.ts +54 -0
  269. package/src/primitives.ts +136 -0
  270. package/src/react-native.d.ts +57 -0
  271. package/src/react-native.ts +285 -0
  272. package/src/react-peers.d.ts +21 -3
  273. package/src/replay.d.ts +185 -0
  274. package/src/replay.ts +6 -0
  275. package/src/sandbox/build.ts +119 -0
  276. package/src/sandbox/create.ts +187 -0
  277. package/src/sandbox/engine.ts +267 -0
  278. package/src/sandbox/generated.ts +195 -0
  279. package/src/sandbox/kernel.ts +180 -0
  280. package/src/sandbox/mirror.ts +269 -0
  281. package/src/sandbox/protocol.ts +128 -0
  282. package/src/sandbox/quickjs.ts +74 -0
  283. package/src/sandbox/ssr.ts +41 -0
  284. package/src/sandbox/widget.ts +60 -0
  285. package/src/sandbox/worker-entry.ts +59 -0
  286. package/src/sandbox/worker.ts +137 -0
  287. package/src/sandbox-build.d.ts +12 -0
  288. package/src/sandbox-build.ts +6 -0
  289. package/src/sandbox.d.ts +299 -0
  290. package/src/sandbox.ts +25 -0
  291. package/src/shared.ts +12 -0
  292. package/src/terminal/paint.ts +712 -0
  293. package/src/terminal.d.ts +167 -0
  294. package/src/terminal.ts +530 -0
  295. package/src/three/assets.ts +170 -0
  296. package/src/three/host.ts +36 -0
  297. package/src/three/scene.ts +217 -0
  298. package/src/three/target.ts +252 -0
  299. package/src/three.d.ts +221 -0
  300. package/src/three.ts +30 -0
  301. package/src/ui/accordion.ts +21 -7
  302. package/src/ui/dialog.ts +192 -90
  303. package/src/ui/disclosure.ts +2 -0
  304. package/src/ui/dom.ts +30 -0
  305. package/src/ui/layers.ts +132 -0
  306. package/src/ui/popover.ts +120 -21
  307. package/src/ui/shared.ts +98 -12
  308. package/src/ui/stack.ts +18 -0
  309. package/src/ui/tabs.ts +2 -0
  310. package/src/ui/toaster.ts +16 -15
  311. package/src/ui/tooltip.ts +24 -8
  312. package/src/ui/tooltips.ts +26 -14
  313. package/src/ui/zag/combobox.ts +180 -13
  314. package/src/ui/zag/menu.ts +97 -17
  315. package/src/ui/zag/select.ts +15 -4
  316. package/src/ui/zag/shared.ts +60 -8
  317. package/src/ui-combobox.d.ts +16 -1
  318. package/src/ui-menu.d.ts +22 -7
  319. package/src/ui-zag-types.d.ts +13 -5
  320. package/src/ui.d.ts +62 -7
  321. package/src/ui.ts +2 -2
  322. package/src/vite/api.ts +186 -0
  323. package/src/vite/compile.ts +280 -0
  324. package/src/vite/plugin.d.ts +34 -0
  325. package/src/vite/plugin.ts +78 -4
  326. package/src/vite/sandbox.ts +16 -0
  327. package/src/worker/page.ts +396 -0
  328. package/src/worker/protocol.ts +48 -0
  329. package/src/worker/target.ts +282 -0
  330. package/src/worker-page.d.ts +42 -0
  331. package/src/worker-page.ts +12 -0
  332. package/src/worker.d.ts +65 -0
  333. package/src/worker.ts +16 -0
  334. package/src/yjs/driver.ts +253 -0
  335. package/src/yjs/mapping.ts +389 -0
  336. package/src/yjs/shared.ts +138 -0
  337. package/src/yjs/testing.ts +91 -0
  338. package/src/yjs-testing.d.ts +34 -0
  339. package/src/yjs-testing.ts +7 -0
  340. package/src/yjs.d.ts +109 -0
  341. package/src/yjs.ts +18 -0
  342. package/src/zag.d.ts +2 -0
  343. package/src/zag.ts +8 -1
@@ -0,0 +1,712 @@
1
+ /**
2
+ * sygnal/terminal: the painter. A line-flow layout of the host tree (block elements start lines,
3
+ * inline content flows and wraps at the width, lists indent with bullets or numbers), form
4
+ * widgets drawn as text, and the focused element marked. Boxes (TUI spike): an element whose
5
+ * terminal style has a width, height, border, padding or overflow is laid out in a rectangle of
6
+ * its own (a flex row of sized children side by side, a flex column sharing a height, a
7
+ * scrolling region with a scrollbar that keeps the focus in view). Open modal dialogs, open
8
+ * popovers and a <select>'s open option list are drawn over the page (the top layer). Widths are
9
+ * terminal cells: graphemes, with wide (CJK, emoji) ones two cells. Two outputs from one pass:
10
+ * plain text (tests, logs, snapshots) and ANSI-styled lines (a TTY), plus where each element is
11
+ * (hit regions: a mouse click) and the caret.
12
+ */
13
+ import type {HostDocument, HostElement, HostNode, HostStyle} from '../host'
14
+ import {computeStyle, matches, isTextField, isCheckable, optionValue, closedByHost} from '../host'
15
+
16
+ export interface TerminalStyle extends HostStyle {
17
+ /** 'none' hides; 'block' / 'inline' override the tag's default; 'flex' with flexDirection */
18
+ display?: string
19
+ /** text added before / after the element's content */
20
+ before?: string
21
+ after?: string
22
+ bold?: boolean
23
+ dim?: boolean
24
+ italic?: boolean
25
+ underline?: boolean
26
+ inverse?: boolean
27
+ /** an ANSI color: black red green yellow blue magenta cyan white gray (bright: brightRed, …) */
28
+ color?: string
29
+ /** the background, the same colors */
30
+ background?: string
31
+ /** the element's text in upper case */
32
+ upper?: boolean
33
+ /** a box's width in cells, or a percentage of the space it is in ('30%') */
34
+ width?: number | string
35
+ /** a box's height in rows, or a percentage of its parent box's (the screen's for the root) */
36
+ height?: number | string
37
+ /** in a flex row or column: the share of the space left (1, 2, …) */
38
+ flex?: number
39
+ /** a box's border: true / 'single', 'round', 'double', 'heavy' */
40
+ border?: boolean | 'single' | 'round' | 'double' | 'heavy'
41
+ borderColor?: string
42
+ /** cells inside the border: n (all sides) or [vertical, horizontal] */
43
+ padding?: number | [number, number]
44
+ /** a box with a height: 'auto' / 'scroll' scroll (a scrollbar; the focus kept in view), 'hidden' crops; default: the content's height */
45
+ overflow?: 'visible' | 'hidden' | 'auto' | 'scroll'
46
+ /** between the items of a flex row (cells, default 1) */
47
+ gap?: number
48
+ }
49
+ export type TerminalStyleRule = [selector: string, style: TerminalStyle]
50
+
51
+ /** applied before an app's own rules */
52
+ export const defaultTerminalStyles: TerminalStyleRule[] = [
53
+ ['h1, h2, h3, h4, h5, h6, th, strong, b, legend', {bold: true}],
54
+ ['em, i, cite', {italic: true}],
55
+ ['a[href]', {underline: true, color: 'cyan'}],
56
+ ['code, kbd', {color: 'yellow'}],
57
+ ['script, style, template, head, title, noscript', {display: 'none'}],
58
+ ['input[type="hidden"]', {display: 'none'}],
59
+ ['[disabled]', {dim: true}],
60
+ ['dialog', {border: 'round', padding: [0, 1]}],
61
+ ]
62
+
63
+ const BLOCK = /^(root|div|section|header|footer|main|nav|article|aside|ul|ol|li|form|fieldset|h[1-6]|p|pre|table|tr|details|summary|dialog|blockquote|figure|figcaption|address|dl|dt|dd|hr|menu|search)$/
64
+ const COLORS: Record<string, number> = {black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90, grey: 90}
65
+ const colorCode = (name: string, bg: boolean) => {
66
+ const m = /^bright(\w+)$/.exec(name), base = COLORS[(m ? m[1] : name).toLowerCase()]
67
+ return base ? (m ? base + 60 : base) + (bg ? 10 : 0) : 0
68
+ }
69
+ const sgrOf = (st: TerminalStyle): number[] => {
70
+ const c: number[] = []
71
+ if (st.bold) c.push(1)
72
+ if (st.dim) c.push(2)
73
+ if (st.italic) c.push(3)
74
+ if (st.underline) c.push(4)
75
+ if (st.inverse) c.push(7)
76
+ if (st.color && colorCode(st.color, false)) c.push(colorCode(st.color, false))
77
+ if (st.background && colorCode(st.background, true)) c.push(colorCode(st.background, true))
78
+ return c
79
+ }
80
+
81
+ // ── cells: graphemes and their widths ────────────────────────────────
82
+ const segmenter: any = typeof Intl != 'undefined' && (Intl as any).Segmenter ? new (Intl as any).Segmenter() : null
83
+ // text whose every UTF-16 unit is one grapheme of one cell: ASCII, Latin-1 and Latin Extended,
84
+ // general punctuation, arrows, box drawing, blocks, geometric shapes (not the emoji ones). Most of
85
+ // a frame is this (borders, bullets, padding); it needs no segmenter (TUI spike: that was 95% of a paint)
86
+ const SIMPLE = /^[\x20-\x7e -˿‐-‧‰-⁞←-⇿─-◻]*$/
87
+ /** a string's graphemes (user-perceived characters) */
88
+ export const graphemes = (s: string): string[] => SIMPLE.test(s) ? s.split('') : segmenter ? Array.from(segmenter.segment(s), (g: any) => g.segment) : [...s]
89
+ const WIDE: [number, number][] = [
90
+ [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf],
91
+ [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60],
92
+ [0xffe0, 0xffe6], [0x20000, 0x2fffd], [0x30000, 0x3fffd],
93
+ ]
94
+ const ZERO = /^[\p{Mn}\p{Me}\p{Cf}​⁠]+$/u
95
+ const EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}️|\p{Regional_Indicator}/u
96
+ // how wide the terminal draws an emoji: 2 by Unicode 9+ (most terminals), 1 in a terminal on the
97
+ // older tables (xterm.js by default: VS Code's, the Claude app's). runTerminal asks the terminal
98
+ // (emojiWidth: 'auto'); paint({ emojiWidth }) sets it for a paint
99
+ let emojiCells = 2
100
+ const widths = [new Map<string, number>(), new Map<string, number>()]
101
+ /** a grapheme's width in terminal cells: 0 (combining, controls), 1, or 2 (CJK, fullwidth, emoji: `emojiWidth`, default 2) */
102
+ export function charWidth(g: string, emojiWidth = emojiCells): number {
103
+ const c = g.codePointAt(0) || 0
104
+ if (c < 32 || (c >= 0x7f && c < 0xa0)) return 0
105
+ if (g.length == 1 && c < 0x300) return 1
106
+ if (ZERO.test(g)) return 0
107
+ if (EMOJI.test(g)) return emojiWidth
108
+ if (c < 0x1100) return 1
109
+ for (const [a, b] of WIDE) if (c >= a && c <= b) return 2
110
+ return 1
111
+ }
112
+ /** a string's width in terminal cells (`emojiWidth`: as charWidth) */
113
+ export const stringWidth = (s: string, emojiWidth = emojiCells): number => {
114
+ if (SIMPLE.test(s)) return s.length
115
+ const cache = widths[emojiWidth == 1 ? 1 : 0]
116
+ let n = cache.get(s)
117
+ if (n === undefined) {
118
+ n = 0
119
+ for (const g of graphemes(s)) n += charWidth(g, emojiWidth)
120
+ if (cache.size > 5000) cache.clear()
121
+ cache.set(s, n)
122
+ }
123
+ return n
124
+ }
125
+
126
+ // ── lines of styled segments ─────────────────────────────────────────
127
+ /** a run of text with its styles; `el` the element it belongs to (hit regions), `caret` the caret cell */
128
+ type Seg = {text: string, sgr: number[], el?: HostElement, caret?: boolean}
129
+ type Line = Seg[]
130
+ /** where an element is drawn: a run of cells on one line */
131
+ export interface Hit {line: number, col: number, width: number, el: HostElement}
132
+ interface Block {lines: Line[]}
133
+
134
+ const lineWidth = (l: Line) => l.reduce((n, s) => n + stringWidth(s.text), 0)
135
+ const blank = (n: number, sgr: number[] = []): Seg => ({text: ' '.repeat(Math.max(0, n)), sgr})
136
+ /** the cells [from, to) of a line (a wide grapheme cut in half becomes a space) */
137
+ function cut(line: Line, from: number, to = Infinity): Line {
138
+ const out: Line = []
139
+ let col = 0
140
+ for (const s of line) {
141
+ const w = stringWidth(s.text)
142
+ if (col + w <= from || col >= to) { col += w; continue }
143
+ if (col >= from && col + w <= to) { out.push(s); col += w; continue }
144
+ if (SIMPLE.test(s.text)) { out.push({...s, text: s.text.slice(Math.max(0, from - col), to - col)}); col += w; continue }
145
+ let t = ''
146
+ for (const g of graphemes(s.text)) {
147
+ const gw = charWidth(g)
148
+ if (col >= from && col + gw <= to) t += g
149
+ else if (col < to && col + gw > from) t += ' '.repeat(Math.min(to, col + gw) - Math.max(from, col))
150
+ col += gw
151
+ }
152
+ if (t) out.push({...s, text: t})
153
+ }
154
+ return out
155
+ }
156
+ const padTo = (l: Line, w: number, sgr: number[] = []): Line => {
157
+ const n = lineWidth(l)
158
+ return n > w ? cut(l, 0, w) : n < w ? [...l, blank(w - n, sgr)] : l
159
+ }
160
+
161
+ const addLine = (b: Block, line: Line) => { b.lines.push(line) }
162
+ const empty = (): Block => ({lines: []})
163
+ /** where each element is (its segments' cells, merged on a line) and the caret: read off the cells */
164
+ function scan(lines: Line[]): {hits: Hit[], caret: {line: number, col: number} | null} {
165
+ const hits: Hit[] = []
166
+ let caret: {line: number, col: number} | null = null
167
+ lines.forEach((line, y) => {
168
+ let col = 0
169
+ for (const s of line) {
170
+ const w = stringWidth(s.text)
171
+ if (s.caret && !caret) caret = {line: y, col}
172
+ if (s.el && w) {
173
+ const h = hits[hits.length - 1]
174
+ if (h && h.el === s.el && h.line === y && h.col + h.width === col) h.width += w
175
+ else hits.push({line: y, col, width: w, el: s.el})
176
+ }
177
+ col += w
178
+ }
179
+ })
180
+ return {hits, caret}
181
+ }
182
+ /** place `src` into `dst` at (y, x), over dst's cells */
183
+ function place(dst: Block, src: Block, y: number, x: number, w?: number) {
184
+ const sw = w ?? Math.max(0, ...src.lines.map(lineWidth))
185
+ src.lines.forEach((l, i) => {
186
+ const row = dst.lines[y + i] || []
187
+ dst.lines[y + i] = [...padTo(cut(row, 0, x), x), ...padTo(l, sw), ...cut(row, x + sw)]
188
+ })
189
+ for (let i = dst.lines.length; i--;) if (!dst.lines[i]) dst.lines[i] = []
190
+ }
191
+
192
+ function wrap(segs: Seg[], width: number): Seg[][] {
193
+ if (width <= 0 || lineWidth(segs) <= width) return [segs]
194
+ // words with their styles; a word longer than the width is cut
195
+ const words: Seg[][] = [[]]
196
+ for (const s of segs) for (const part of s.text.split(/( )/)) {
197
+ if (part == ' ') words.push([{...s, text: ' '}], [])
198
+ else if (part) words[words.length - 1].push({...s, text: part})
199
+ }
200
+ const lines: Seg[][] = [[]]
201
+ let n = 0
202
+ for (const w of words) {
203
+ const len = lineWidth(w)
204
+ if (!len && !w.some(s => s.caret)) continue
205
+ const space = w.length == 1 && w[0].text == ' '
206
+ if (n + len > width && n > 0 && !space) { lines.push([]); n = 0 }
207
+ if (space && n == 0) continue
208
+ // a space that doesn't fit ends the line
209
+ if (space && n + len > width) { lines.push([]); n = 0; continue }
210
+ for (const s of w) {
211
+ let t = graphemes(s.text)
212
+ const tw = () => t.reduce((k, g) => k + charWidth(g), 0)
213
+ while (n + tw() > width && width > 0) {
214
+ let take = 0, cw = 0
215
+ while (take < t.length && n + cw + charWidth(t[take]) <= width) cw += charWidth(t[take++])
216
+ if (!take && !n) cw += charWidth(t[take++])
217
+ if (take) lines[lines.length - 1].push({...s, text: t.slice(0, take).join('')})
218
+ t = t.slice(take); lines.push([]); n = 0
219
+ }
220
+ if (t.length || s.caret) { lines[lines.length - 1].push({...s, text: t.join('')}); n += tw() }
221
+ }
222
+ }
223
+ return lines
224
+ }
225
+
226
+ export interface Frame {
227
+ /** plain text, one string per line */
228
+ plain: string[]
229
+ /** the same lines with ANSI styles */
230
+ ansi: string[]
231
+ /** the line the focused element is on (-1: none) */
232
+ focusLine: number
233
+ /** where the focused element starts (null: none) */
234
+ focus: {line: number, col: number} | null
235
+ /** the caret of the focused text field (null: none) */
236
+ caret: {line: number, col: number} | null
237
+ /** where each element is drawn (innermost elements, top layer last): hitTest(frame, line, col) */
238
+ hits: Hit[]
239
+ }
240
+ export interface PaintOptions {
241
+ rules?: readonly TerminalStyleRule[]
242
+ width?: number
243
+ /** the screen's rows: the root's height for percentages, flex columns and centering a modal dialog */
244
+ height?: number
245
+ /** how many cells the terminal draws an emoji in: 2 (default; Unicode 9+) or 1 (older width tables: xterm.js by default) */
246
+ emojiWidth?: 1 | 2
247
+ }
248
+ /** the innermost element drawn at a cell (the top layer first), else null */
249
+ export function hitTest(frame: Frame, line: number, col: number): HostElement | null {
250
+ for (let i = frame.hits.length; i--;) {
251
+ const h = frame.hits[i]
252
+ if (h.line === line && col >= h.col && col < h.col + h.width) return h.el
253
+ }
254
+ return null
255
+ }
256
+
257
+ interface Ctx {rules: TerminalStyleRule[], doc: HostDocument, focus: HostElement | null, sheet: Map<HostElement, TerminalStyle>}
258
+ // the box keys come from the terminal stylesheet only: an inline style is the web's CSS (a React
259
+ // Native style object's flex: 1 or gap: 12 through sygnal/primitives isn't a terminal layout)
260
+ const BOX_KEYS = ['width', 'height', 'flex', 'border', 'borderColor', 'padding', 'overflow', 'gap', 'background']
261
+ const styleOf = (ctx: Ctx, el: HostElement): TerminalStyle => {
262
+ let st = ctx.sheet.get(el)
263
+ if (!st) {
264
+ st = computeStyle(el, ctx.rules)
265
+ if (BOX_KEYS.some(k => el.style[k] != null && el.style[k] !== '')) {
266
+ const sheet: TerminalStyle = {}
267
+ for (const [sel, x] of ctx.rules) if (matches(el, sel)) Object.assign(sheet, x)
268
+ for (const k of BOX_KEYS) k in sheet ? (st[k] = sheet[k]) : delete st[k]
269
+ }
270
+ ctx.sheet.set(el, st)
271
+ }
272
+ return st
273
+ }
274
+ // the box keys take terminal values only: cells (numbers) and percentages. A web style's CSS
275
+ // lengths ('8px', '1rem': an inline style={{ padding: 8 }} of sygnal/primitives) aren't cells
276
+ const cells = (v: unknown) => typeof v == 'number' || (typeof v == 'string' && /^\s*\d+(\.\d+)?%\s*$/.test(v))
277
+ const size = (v: unknown, of: number | undefined): number | undefined => {
278
+ if (typeof v == 'number') return Math.max(0, Math.floor(v))
279
+ if (!cells(v)) return undefined
280
+ return of == null ? undefined : Math.floor(of * parseFloat(v as string) / 100)
281
+ }
282
+ const pads = (p: unknown): [number, number] => typeof p == 'number' ? [p | 0, p | 0] : Array.isArray(p) && typeof p[0] == 'number' ? [p[0] | 0, p[1] | 0] : [0, 0]
283
+ const borderOf = (b: unknown): string => (b === true ? 'single' : typeof b == 'string' && b in BORDERS ? b : '')
284
+ const isBox = (st: TerminalStyle) => cells(st.width) || cells(st.height) || !!borderOf(st.border) || pads(st.padding).some(Boolean)
285
+ const flexOf = (st: TerminalStyle): number | undefined => (typeof st.flex == 'number' ? st.flex : undefined)
286
+ const flexDir = (st: TerminalStyle): '' | 'row' | 'column' => /flex$/.test(st.display || '') ? (/^column/.test(st.flexDirection || '') ? 'column' : 'row') : ''
287
+ const shown = (ctx: Ctx, n: HostNode): boolean => {
288
+ if (n.nodeType === 3) return !!(n as any).data.trim()
289
+ const el = n as HostElement
290
+ return !closedByHost(el) && styleOf(ctx, el).display != 'none' && !el.modal && !el.popoverOpen
291
+ }
292
+ /** a flex row laid out as columns: one of its items has a width, a flex share or a border */
293
+ const columns = (ctx: Ctx, el: HostElement, st: TerminalStyle) => flexDir(st) == 'row' &&
294
+ el.children.some(c => { const s = styleOf(ctx, c); return cells(s.width) || flexOf(s) != null || !!borderOf(s.border) })
295
+
296
+ const BORDERS: Record<string, string> = {single: '┌┐└┘─│', round: '╭╮╰╯─│', double: '╔╗╚╝═║', heavy: '┏┓┗┛━┃'}
297
+ // each box remembers what it last kept in view, so a box the user scrolled stays where it is
298
+ const followed = new WeakMap<HostElement, HostElement | null>()
299
+
300
+
301
+ /**
302
+ * The flow of `root`'s children at `width`: block elements start lines, inline content wraps.
303
+ * `sgr` / `upper` are inherited; `availH` the height percentages resolve against.
304
+ */
305
+ function flowBlock(root: HostElement, width: number, ctx: Ctx, sgr: number[], upper: boolean, availH: number | undefined, own?: TerminalStyle): Block {
306
+ const out = empty()
307
+ let cur: Seg[] = []
308
+ // a list item's marker waits for the item's first line
309
+ let marker = ''
310
+
311
+ const push = (text: string, sgr: number[], upper: boolean, el?: HostElement, caret?: boolean) => {
312
+ if (text || caret) cur.push({text: upper ? text.toUpperCase() : text, sgr, el, caret})
313
+ }
314
+ const lead = (indent: string, first: boolean): Seg => ({text: first && marker ? indent.slice(0, indent.length - marker.length) + marker : indent, sgr: []})
315
+ const flush = (indent: string, force = false) => {
316
+ // collapse the whitespace runs across segments; trim the line's ends
317
+ const segs: Seg[] = []
318
+ let prevSpace = true
319
+ for (const s of cur) {
320
+ let t = ''
321
+ for (const ch of s.text.replace(/\s/g, ' ')) { if (ch == ' ' && prevSpace) continue; t += ch; prevSpace = ch == ' ' }
322
+ if (t || s.caret) segs.push({...s, text: t})
323
+ }
324
+ while (segs.length && /^ *$/.test(segs[segs.length - 1].text) && !segs[segs.length - 1].caret) segs.pop()
325
+ if (segs.length) segs[segs.length - 1] = {...segs[segs.length - 1], text: segs[segs.length - 1].text.replace(/ +$/, '')}
326
+ cur = []
327
+ if (!segs.length && !force) return
328
+ const lines = wrap(segs, width - indent.length)
329
+ for (const l of lines) if (l.length) l[l.length - 1] = {...l[l.length - 1], text: l[l.length - 1].text.replace(/ +$/, '')}
330
+ lines.forEach((line, i) => addLine(out, [lead(indent, i == 0), ...line]))
331
+ if (lines.length) marker = ''
332
+ }
333
+ /** a laid-out box in the flow, at the indent */
334
+ const box = (b: Block, indent: string) => {
335
+ flush(indent)
336
+ b.lines.forEach((l, i) => addLine(out, [lead(indent, i == 0), ...l]))
337
+ if (b.lines.length) marker = ''
338
+ }
339
+ const label = (el: HostElement) => (el.textContent.trim() || el.getAttribute('value') || el.getAttribute('aria-label') || el.getAttribute('title') || '').replace(/\s+/g, ' ')
340
+
341
+ // flex: a column's children each take their own lines, a row's are separated (their gap)
342
+ const walk = (n: HostNode, indent: string, sgr: number[], upper: boolean, flex: '' | 'column' | 'row' = '') => {
343
+ if (n.nodeType === 3) return push((n as any).data, sgr, upper, n.parentNode || undefined)
344
+ const el = n as HostElement
345
+ if (!shown(ctx, el)) return
346
+ const st0 = styleOf(ctx, el)
347
+ if (flex == 'column') { flush(indent); walkEl(el, indent, sgr, upper, st0); return flush(indent) }
348
+ walkEl(el, indent, sgr, upper, st0)
349
+ if (flex == 'row') push(' ', sgr, false)
350
+ }
351
+ const walkEl = (el: HostElement, indent: string, sgr: number[], upper: boolean, st: TerminalStyle) => {
352
+ const tag = el.localName
353
+ const flexKids = flexDir(st)
354
+ const focused = ctx.focus === el
355
+ const blockTag = BLOCK.test(tag)
356
+ const block = st.display ? !/^inline/.test(st.display) && st.display != 'contents' : blockTag
357
+ const s = [...sgr, ...sgrOf(st)], up = upper || !!st.upper
358
+ const mark = focused ? s.concat(7) : s
359
+ // a widget is a box: spaces around it (collapsed with any next to it)
360
+ // (the spaces in the parent's style: an underlined tab's isn't underlined past its text)
361
+ const widget = (text: string) => { push(' ', sgr, false); push(st.before || '', s, up); push(focused ? '▸' : '', s, false, el); push(text, mark, false, el); push(st.after || '', s, up); push(' ', sgr, false) }
362
+ if (tag == 'br') return flush(indent, true)
363
+ if (tag == 'hr') { flush(indent); push('─'.repeat(Math.max(3, width - indent.length)), [...s, 2], false); return flush(indent) }
364
+ if (tag == 'img') return widget(`[${el.getAttribute('alt') || 'image'}]`)
365
+ if (tag == 'textarea' || isBox(st) || columns(ctx, el, st)) {
366
+ return box(layoutBox(el, width - indent.length, availH, ctx, sgr, upper), indent)
367
+ }
368
+ if (isCheckable(el)) {
369
+ return widget(el.type == 'radio' ? (el.checked ? '(•)' : '( )') : (el.checked ? '[x]' : '[ ]'))
370
+ }
371
+ if (isTextField(el)) {
372
+ push(' ', sgr, false)
373
+ push(st.before || '', s, up)
374
+ if (focused) push('▸', s, false, el)
375
+ fieldSegs(el, focused, s).forEach(x => cur.push(x))
376
+ push(st.after || '', s, up)
377
+ return push(' ', sgr, false)
378
+ }
379
+ if (tag == 'select') {
380
+ const o = el.options.find(o => optionValue(o) == el.value) || el.options[0]
381
+ return widget(`[${o ? o.textContent.trim() : ''} ${el.picker != null ? '▴' : '▾'}]`)
382
+ }
383
+ if (tag == 'progress' || tag == 'meter') {
384
+ const max = +(el.getAttribute('max') || 1) || 1, v = el.getAttribute('value')
385
+ if (v == null) return widget('[' + '░'.repeat(10) + ']')
386
+ const f = Math.max(0, Math.min(1, +v / max)), n = Math.round(f * 10)
387
+ return widget('[' + '█'.repeat(n) + '░'.repeat(10 - n) + '] ' + Math.round(f * 100) + '%')
388
+ }
389
+ if (tag == 'button' || (tag == 'input' && /^(button|submit|reset)$/.test(el.type))) return widget(`[${label(el) || (el.type == 'submit' ? 'Submit' : '…')}]`)
390
+
391
+ if (block) {
392
+ flush(indent)
393
+ let inner = indent
394
+ // a list indents when its items are blocks (a row of inline items, a nav's, doesn't)
395
+ if ((tag == 'ul' || tag == 'ol' || tag == 'menu') && el.children.some(c => c.localName == 'li' && !/^inline/.test(styleOf(ctx, c).display || ''))) inner = indent + ' '
396
+ if (tag == 'li') {
397
+ const p = el.parentNode
398
+ marker = p?.localName == 'ol' ? `${p.children.filter(c => c.localName == 'li').indexOf(el) + 1}. ` : '• '
399
+ inner = indent + ' '.repeat(marker.length)
400
+ }
401
+ if (tag == 'blockquote' || tag == 'dd') inner = indent + ' '
402
+ if (focused) push('▸', s, false, el)
403
+ // a <details>' disclosure triangle on its summary
404
+ if (tag == 'summary' && el.parentNode?.localName == 'details') push(el.parentNode.open ? '▾ ' : '▸ ', s, false, el)
405
+ push(st.before || '', focused ? mark : s, up, el)
406
+ if (tag == 'pre') {
407
+ for (const line of el.textContent.split('\n')) { push(line.replace(/ /g, ''), focused ? mark : s, up, el); flush(inner, true) }
408
+ } else for (const c of el.childNodes) walk(c, inner, focused ? mark : s, up, flexKids)
409
+ push(st.after || '', s, up, el)
410
+ flush(inner)
411
+ if (tag == 'li') marker = ''
412
+ return
413
+ }
414
+ if (focused) push('▸', s, false, el)
415
+ push(st.before || '', focused ? mark : s, up, el)
416
+ for (const c of el.childNodes) walk(c, indent, focused ? mark : s, up, flexKids)
417
+ push(st.after || '', s, up, el)
418
+ // a block tag shown inline (a nav's <li>s in a row) is separated like a box
419
+ if (blockTag) push(' ', s, false)
420
+ }
421
+ if (own) {
422
+ const s = [...sgr, ...sgrOf(own)], up = upper || !!own.upper, flexKids = flexDir(own)
423
+ push(own.before || '', s, up, root)
424
+ for (const c of root.childNodes) walk(c, '', s, up, flexKids)
425
+ push(own.after || '', s, up, root)
426
+ } else for (const c of root.childNodes) walk(c, '', sgr, upper)
427
+ flush('')
428
+ return out
429
+ }
430
+
431
+ /** a text field's segments: [value] with the caret and the selection (inverse); the placeholder when empty */
432
+ function fieldSegs(el: HostElement, focused: boolean, s: number[]): Seg[] {
433
+ const pw = el.type == 'password'
434
+ const v = el.value, a = el.selectionStart, b = el.selectionEnd, inv = s.concat(7), fs = focused ? s.concat(4) : s
435
+ if (!v) return focused ? [{text: '[', sgr: s, el}, {text: '_', sgr: inv, el, caret: true}, {text: ']', sgr: s, el}]
436
+ : [{text: '[' + el.placeholder + ']', sgr: s, el}]
437
+ const show = (t: string) => (pw ? '•'.repeat(graphemes(t).length) : t.replace(/\n/g, '⏎'))
438
+ const out: Seg[] = [{text: '[', sgr: s, el}]
439
+ if (!focused) out.push({text: show(v), sgr: s, el})
440
+ else {
441
+ out.push({text: show(v.slice(0, a)), sgr: fs, el})
442
+ if (a < b) out.push({text: show(v.slice(a, b)), sgr: inv, el, caret: el.selectionDirection == 'backward'})
443
+ // the caret cell: the grapheme after the caret, inverse (after the end: '_')
444
+ const rest = v.slice(b), g = graphemes(rest)[0]
445
+ if (a == b) out.push({text: g ? show(g) : '_', sgr: inv, el, caret: true})
446
+ else if (el.selectionDirection != 'backward') out.push({text: '', sgr: fs, el, caret: true})
447
+ out.push({text: show(a == b && g ? rest.slice(g.length) : rest), sgr: fs, el})
448
+ }
449
+ out.push({text: ']', sgr: s, el})
450
+ return out.filter(x => x.text || x.caret)
451
+ }
452
+
453
+ /** a <textarea>'s content: its lines hard-wrapped at `w` cells, the caret and selection marked */
454
+ function textareaBlock(el: HostElement, w: number, focused: boolean, s: number[]): Block {
455
+ const out = empty(), v = el.value, a = el.selectionStart, b = el.selectionEnd, inv = s.concat(7)
456
+ if (!v && !focused) { addLine(out, [{text: el.placeholder, sgr: s.concat(2), el}]); return out }
457
+ let line: Line = [], col = 0, i = 0
458
+ const nl = () => { addLine(out, line.length ? line : [{text: '', sgr: s, el}]); line = []; col = 0 }
459
+ const caretHere = (at: number) => focused && a == b && at == a
460
+ for (const g of graphemes(v)) {
461
+ if (g == '\n' || g == '\r\n') {
462
+ if (caretHere(i)) line.push({text: ' ', sgr: inv, el, caret: true})
463
+ nl(); i += g.length; continue
464
+ }
465
+ const gw = charWidth(g)
466
+ if (col + gw > w && col > 0) nl()
467
+ const sel = focused && i >= a && i < b
468
+ line.push({text: g, sgr: sel || caretHere(i) ? inv : s, el, caret: caretHere(i) || (focused && a < b && i == b)})
469
+ col += gw; i += g.length
470
+ }
471
+ if (caretHere(v.length)) { if (col + 1 > w && col > 0) nl(); line.push({text: ' ', sgr: inv, el, caret: true}) }
472
+ nl()
473
+ return out
474
+ }
475
+
476
+ /**
477
+ * A box: `el` laid out in its own rectangle, `availW` wide at most. Its width (style, else all of
478
+ * availW), its content (a flex row's columns, a flex column's shares, else the flow; a textarea's
479
+ * text), then its height (style, `forcedH` from a parent flex, else the content's), scrolled to
480
+ * keep the focus in view, a scrollbar when it scrolls, its padding and its border.
481
+ */
482
+ function layoutBox(el: HostElement, availW: number, availH: number | undefined, ctx: Ctx, sgr: number[], upper: boolean, forcedH?: number, natural = false, forcedW?: number): Block {
483
+ const st = styleOf(ctx, el), tag = el.localName, focused = ctx.focus === el
484
+ const ta = tag == 'textarea'
485
+ const bstyle = st.border === false ? '' : borderOf(st.border) || (ta ? 'single' : '')
486
+ const B = bstyle ? 1 : 0, [pv, ph] = pads(st.padding)
487
+ const s = [...sgr, ...sgrOf(st)], up = upper || !!st.upper
488
+ // (rows / cols: the attribute, or the property the props module set)
489
+ const cols = ta ? +(el.getAttribute('cols') || (el as any).cols || 0) : 0, trows = ta ? +(el.getAttribute('rows') || (el as any).rows || 2) || 2 : 0
490
+ let W = forcedW ?? Math.min(availW, size(st.width, availW) ?? (cols ? cols + 2 * B + 2 * ph : availW))
491
+ let H = forcedH ?? size(st.height, availH) ?? (ta ? trows + 2 * B + 2 * pv : undefined)
492
+ const ih = H == null ? undefined : Math.max(0, H - 2 * B - 2 * pv)
493
+ const scrolls = ta || st.overflow == 'auto' || st.overflow == 'scroll'
494
+ const content = (iw: number): Block => {
495
+ if (ta) return textareaBlock(el, iw, focused, s)
496
+ const dir = flexDir(st)
497
+ if (dir == 'row' && columns(ctx, el, st)) return rowBlock(el, iw, ih, ctx, s, up, st)
498
+ if (dir == 'column' && ih != null && el.children.some(c => flexOf(styleOf(ctx, c)) != null)) return columnBlock(el, iw, ih, ctx, s, up)
499
+ return flowBlock(el, iw, ctx, sgr, upper, ih ?? availH, st)
500
+ }
501
+ let iw = Math.max(1, W - 2 * B - 2 * ph)
502
+ let c = content(iw)
503
+ if (natural && forcedW == null && !cells(st.width) && !cols) {
504
+ // shrink to the content (a dialog, a popover): its widest line, or its title on the border
505
+ const t = B ? (el.getAttribute('aria-label') || el.getAttribute('title') || '').trim() : ''
506
+ const nw = Math.max(1, ...c.lines.map(lineWidth), t ? stringWidth(t) + 4 - 2 * ph : 0)
507
+ if (nw < iw) { iw = nw; W = iw + 2 * B + 2 * ph; c = content(iw) }
508
+ }
509
+ let bar = false
510
+ if (ih != null && scrolls && c.lines.length > ih && iw > 1) { bar = true; iw -= 1; c = content(iw) }
511
+ // a focused box without a border (a border shows the focus heavy): the mark before its first line
512
+ if (focused && !B && !ta) c.lines[0] = cut([{text: '▸', sgr: s, el}, ...(c.lines[0] || [])], 0, iw)
513
+ // the rows in view
514
+ let top = 0
515
+ if (ih != null && c.lines.length > ih && (/^(auto|scroll|hidden)$/.test(st.overflow || '') || ta)) {
516
+ top = el.scrollTop
517
+ const focus = ctx.focus, target = ctx.doc.scrollTarget && el.contains(ctx.doc.scrollTarget) ? ctx.doc.scrollTarget : null
518
+ const follow = target || (focus && el.contains(focus) && followed.get(el) !== focus ? focus : null)
519
+ const show = (from: number, to: number) => {
520
+ if (to - from >= ih) top = from
521
+ else if (from < top) top = from
522
+ else if (to > top + ih) top = to - ih
523
+ }
524
+ const at = scan(c.lines)
525
+ if (follow) {
526
+ const rows = at.hits.filter(h => follow.contains(h.el)).map(h => h.line)
527
+ if (rows.length) show(Math.min(...rows), Math.max(...rows) + 1)
528
+ }
529
+ // the caret of a field in this box stays in view while typing
530
+ if (at.caret && focus && el.contains(focus)) show(at.caret.line, at.caret.line + 1)
531
+ top = Math.max(0, Math.min(top, c.lines.length - ih))
532
+ if (top !== el.scrollTop) el.scrollTop = top
533
+ }
534
+ if (ctx.focus === null || el.contains(ctx.focus)) followed.set(el, ctx.focus)
535
+ const rows = ih ?? c.lines.length, total = c.lines.length
536
+ const out = empty()
537
+ const fill = s.filter(x => (x >= 40 && x <= 47) || (x >= 100 && x <= 107) || x == 7)
538
+ const bs = [...(st.borderColor ? [colorCode(st.borderColor, false)] : sgr.filter(x => x >= 30 && x <= 37 || x >= 90 && x <= 97)), ...(focused || (ta && ctx.focus === el) ? [1] : [])]
539
+ const ch = BORDERS[focused ? 'heavy' : bstyle] || BORDERS.single
540
+ const innerW = iw + (bar ? 1 : 0) + 2 * ph
541
+ if (B) {
542
+ // a title from aria-label / title on the top border
543
+ const t = (el.getAttribute('aria-label') || el.getAttribute('title') || '').trim()
544
+ const tt = t && innerW > 4 ? cut([{text: ' ' + t + ' ', sgr: bs.concat(1)}], 0, innerW - 2) : []
545
+ const tw = tt.reduce((n, x) => n + stringWidth(x.text), 0)
546
+ addLine(out, [{text: ch[0] + (tw ? ch[4] : ''), sgr: bs, el}, ...tt, {text: ch[4].repeat(Math.max(0, innerW - tw - (tw ? 1 : 0))) + ch[1], sgr: bs, el}])
547
+ }
548
+ for (let i = 0; i < pv; i++) addLine(out, [...(B ? [{text: ch[5], sgr: bs, el}] : []), blank(innerW, fill), ...(B ? [{text: ch[5], sgr: bs, el}] : [])])
549
+ // the scrollbar's thumb
550
+ const thumb = bar ? Math.max(1, Math.round(rows * rows / total)) : 0
551
+ const tpos = bar ? Math.round(top * (rows - thumb) / Math.max(1, total - rows)) : 0
552
+ for (let i = 0; i < rows; i++) {
553
+ const l = padTo(c.lines[top + i] || [], iw, fill)
554
+ const sb: Seg[] = bar ? [{text: i >= tpos && i < tpos + thumb ? '█' : '│', sgr: i >= tpos && i < tpos + thumb ? [] : [2], el}] : []
555
+ addLine(out, [...(B ? [{text: ch[5], sgr: bs, el}] : []), blank(ph, fill), ...l, blank(ph, fill), ...sb, ...(B ? [{text: ch[5], sgr: bs, el}] : [])])
556
+ }
557
+ for (let i = 0; i < pv; i++) addLine(out, [...(B ? [{text: ch[5], sgr: bs, el}] : []), blank(innerW, fill), ...(B ? [{text: ch[5], sgr: bs, el}] : [])])
558
+ if (B) addLine(out, [{text: ch[2] + ch[4].repeat(innerW) + ch[3], sgr: bs, el}])
559
+ return out
560
+ }
561
+
562
+ /** a flex row as columns: fixed widths, natural widths, then the flex shares of what is left; all as tall as the tallest */
563
+ function rowBlock(el: HostElement, w: number, h: number | undefined, ctx: Ctx, sgr: number[], upper: boolean, st: TerminalStyle): Block {
564
+ const items = el.childNodes.filter(n => shown(ctx, n))
565
+ const gap = Math.max(0, st.gap ?? 1)
566
+ const avail = Math.max(0, w - gap * Math.max(0, items.length - 1))
567
+ const lay = (n: HostNode, cw: number, ch?: number): Block => {
568
+ if (n.nodeType === 3) {
569
+ const b = empty()
570
+ addLine(b, [{text: (n as any).data.trim(), sgr, el: n.parentNode || undefined}])
571
+ return b
572
+ }
573
+ const c = n as HostElement, cs = styleOf(ctx, c)
574
+ if (isBox(cs) || c.localName == 'textarea' || flexOf(cs) != null || columns(ctx, c, cs)) return layoutBox(c, cw, h, ctx, sgr, upper, ch, false, cw)
575
+ // an item without a box of its own: its own flow (wrapped in a throwaway parent)
576
+ return flowBlock({childNodes: [c], children: [c]} as any, cw, ctx, sgr, upper, h)
577
+ }
578
+ const widths = items.map(n => n.nodeType === 1 ? size(styleOf(ctx, n as HostElement).width, avail) : undefined)
579
+ const flexes = items.map(n => n.nodeType === 1 ? flexOf(styleOf(ctx, n as HostElement)) : undefined)
580
+ // natural widths for the items with neither
581
+ items.forEach((n, i) => {
582
+ if (widths[i] == null && flexes[i] == null) widths[i] = Math.min(avail, Math.max(0, ...lay(n, avail).lines.map(lineWidth)))
583
+ })
584
+ const used = widths.reduce((a: number, x) => a + (x ?? 0), 0)
585
+ const shares = flexes.reduce((a: number, f, i) => a + (widths[i] == null ? (f || 1) : 0), 0)
586
+ let left = Math.max(0, avail - used)
587
+ items.forEach((_, i) => {
588
+ if (widths[i] != null) return
589
+ const take = Math.floor(left * (flexes[i] || 1) / shares)
590
+ widths[i] = take
591
+ })
592
+ // what the floors left over goes to the last flex item; too wide: the widest shrink
593
+ const sum = () => widths.reduce((a: number, x) => a + (x as number), 0)
594
+ const lastFlex = widths.map((_, i) => i).filter(i => flexes[i] != null).pop()
595
+ if (lastFlex != null && sum() < avail) widths[lastFlex]! += avail - sum()
596
+ while (sum() > avail) { const i = widths.indexOf(Math.max(...(widths as number[]))); if (!widths[i]) break; widths[i]!-- }
597
+ let blocks = items.map((n, i) => lay(n, widths[i]!, h))
598
+ const rows = h ?? Math.max(0, ...blocks.map(b => b.lines.length))
599
+ // stretch: the boxes as tall as the row
600
+ if (h == null) blocks = items.map((n, i) => (n.nodeType === 1 && blocks[i].lines.length < rows && isBox(styleOf(ctx, n as HostElement)) ? lay(n, widths[i]!, rows) : blocks[i]))
601
+ const out = empty()
602
+ for (let y = 0; y < rows; y++) out.lines.push([])
603
+ let x = 0
604
+ blocks.forEach((b, i) => {
605
+ place(out, {...b, lines: b.lines.slice(0, rows)}, 0, x, widths[i]!)
606
+ x += widths[i]! + gap
607
+ })
608
+ return out
609
+ }
610
+
611
+ /** a flex column with a height: the items' own heights, then the flex items share the rows left */
612
+ function columnBlock(el: HostElement, w: number, h: number, ctx: Ctx, sgr: number[], upper: boolean): Block {
613
+ const items = el.children.filter(c => shown(ctx, c))
614
+ const fixed = items.map(c => { const cs = styleOf(ctx, c); return flexOf(cs) != null ? null : layoutBox(c, w, h, ctx, sgr, upper, size(cs.height, h)) })
615
+ // an item without a box style is still laid out as one (its own lines), at its content's height
616
+ const used = fixed.reduce((a, b) => a + (b ? b.lines.length : 0), 0)
617
+ const shares = items.reduce((a, c) => a + (flexOf(styleOf(ctx, c)) ?? 0), 0) || 1
618
+ let left = Math.max(0, h - used)
619
+ const flexIdx = items.map((c, i) => (flexOf(styleOf(ctx, c)) != null ? i : -1)).filter(i => i >= 0)
620
+ const out = empty()
621
+ items.forEach((c, i) => {
622
+ let b = fixed[i]
623
+ if (!b) {
624
+ const share = i === flexIdx[flexIdx.length - 1] ? left : Math.floor(Math.max(0, h - used) * (flexOf(styleOf(ctx, c)) || 0) / shares)
625
+ left -= share
626
+ b = layoutBox(c, w, h, ctx, sgr, upper, share)
627
+ }
628
+ place(out, b, out.lines.length, 0, w)
629
+ })
630
+ if (out.lines.length > h) out.lines.length = h
631
+ while (out.lines.length < h) out.lines.push([])
632
+ return out
633
+ }
634
+
635
+ /** a <select>'s open option list: a bordered list, the highlighted option inverse with ▸, the chosen one ✓ */
636
+ function pickerBlock(sel: HostElement, maxRows: number): Block {
637
+ const opts = sel.options, i = sel.picker ?? 0
638
+ const labels = opts.map(o => o.textContent.trim())
639
+ const iw = Math.max(4, ...labels.map(stringWidth)) + 3
640
+ const rows = Math.max(1, Math.min(opts.length, maxRows))
641
+ let top = Math.max(0, Math.min(sel.scrollTop, opts.length - rows))
642
+ if (i < top) top = i
643
+ if (i >= top + rows) top = i - rows + 1
644
+ if (top !== sel.scrollTop) sel.scrollTop = top
645
+ const out = empty()
646
+ addLine(out, [{text: '┌' + '─'.repeat(iw) + '┐', sgr: [], el: sel}])
647
+ for (let k = top; k < top + rows; k++) {
648
+ const o = opts[k], on = k === i, dis = o.disabled
649
+ const text = (on ? '▸' : ' ') + labels[k] + ' '.repeat(iw - 2 - stringWidth(labels[k])) + (optionValue(o) === sel.value ? '✓' : ' ')
650
+ addLine(out, [{text: '│', sgr: [], el: sel}, {text, sgr: on ? [7] : dis ? [2] : [], el: o}, {text: '│', sgr: [], el: sel}])
651
+ }
652
+ addLine(out, [{text: '└' + '─'.repeat(iw) + '┘', sgr: [], el: sel}])
653
+ return out
654
+ }
655
+
656
+ /** the element a popover is anchored to: its invoker (popovertarget), else what describes or controls it */
657
+ const anchorOf = (doc: HostDocument, p: HostElement): HostElement | null => {
658
+ const id = p.getAttribute('id')
659
+ if (!id) return null
660
+ return doc.body.querySelectorAll('[popovertarget], [aria-describedby], [aria-controls], [aria-details]').find(e =>
661
+ ['popovertarget', 'aria-describedby', 'aria-controls', 'aria-details'].some(a => (e.getAttribute(a) || '').split(/\s+/).includes(id))) || null
662
+ }
663
+
664
+ /** paint a host document */
665
+ export function paint(doc: HostDocument, options: PaintOptions = {}): Frame {
666
+ emojiCells = options.emojiWidth == 1 ? 1 : 2
667
+ const rules = [...defaultTerminalStyles, ...(options.rules || [])]
668
+ const width = options.width ?? 80, height = options.height
669
+ const ctx: Ctx = {rules, doc, focus: doc.activeElement, sheet: new Map()}
670
+ const b = flowBlock(doc.body, width, ctx, [], false, height)
671
+ if (height != null) while (b.lines.length < height) b.lines.push([])
672
+ const H = Math.max(b.lines.length, height ?? 0)
673
+ const pos = (el: HostElement) => scan(b.lines).hits.find(h => h.el === el || el.contains(h.el))
674
+ // the top layer: popovers, then modal dialogs (the page under a modal dimmed), then an open <select>'s list
675
+ for (const p of doc.popovers) {
676
+ if (!p.isConnected || styleOf(ctx, p).display == 'none') continue
677
+ const ov = layoutBox(p, Math.max(1, width - 2), height, ctx, [], false, undefined, true)
678
+ const ow = Math.max(0, ...ov.lines.map(lineWidth)), a = anchorOf(doc, p), at = a && pos(a)
679
+ const y = at ? (at.line + 1 + ov.lines.length <= H || at.line < ov.lines.length ? at.line + 1 : at.line - ov.lines.length) : 0
680
+ place(b, ov, y, at ? Math.max(0, Math.min(at.col, width - ow)) : Math.max(0, width - ow), ow)
681
+ }
682
+ for (const m of doc.modals) {
683
+ if (!m.isConnected || styleOf(ctx, m).display == 'none') continue
684
+ for (const l of b.lines) for (let i = 0; i < l.length; i++) l[i] = {...l[i], sgr: [...l[i].sgr.filter(x => x != 1), 2]}
685
+ const ov = layoutBox(m, Math.max(1, width - 4), height, ctx, [], false, undefined, true)
686
+ const ow = Math.max(0, ...ov.lines.map(lineWidth))
687
+ place(b, ov, Math.max(0, Math.floor((H - ov.lines.length) / 2)), Math.max(0, Math.floor((width - ow) / 2)), ow)
688
+ }
689
+ const sel = doc.activeElement
690
+ if (sel?.localName == 'select' && sel.picker != null) {
691
+ const at = scan(b.lines).hits.find(h => h.el === sel)
692
+ if (at) {
693
+ const below = H - at.line - 1, above = at.line
694
+ const room = Math.max(below, above) - 2
695
+ const ov = pickerBlock(sel, Math.max(1, Math.min(8, room > 0 ? room : 8)))
696
+ const y = ov.lines.length <= below || below >= above ? at.line + 1 : at.line - ov.lines.length
697
+ const ow = lineWidth(ov.lines[0])
698
+ place(b, ov, Math.max(0, y), Math.max(0, Math.min(at.col, width - ow)), ow)
699
+ }
700
+ }
701
+ if (doc.scrollTarget) doc.scrollTarget = null
702
+ // the output: (: a <pre>'s kept space)
703
+ const plain = b.lines.map(l => l.map(s => s.text).join('').replace(//g, ' ').replace(/ +$/, ''))
704
+ const ansi = b.lines.map(l => {
705
+ let end = l.length
706
+ while (end > 0 && !l[end - 1].sgr.length && /^ *$/.test(l[end - 1].text)) end--
707
+ return l.slice(0, end).map(s => s.sgr.length && s.text ? `\x1b[${s.sgr.join(';')}m${s.text}\x1b[0m` : s.text).join('').replace(//g, ' ')
708
+ })
709
+ const {hits, caret} = scan(b.lines)
710
+ const f = ctx.focus, fh = f ? hits.find(h => h.el === f || f.contains(h.el)) : undefined
711
+ return {plain, ansi, focusLine: fh ? fh.line : -1, focus: fh ? {line: fh.line, col: fh.col} : null, caret: f ? caret : null, hits}
712
+ }