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.
- package/CHANGELOG.md +632 -0
- package/dist/agent-ui.cjs.js +592 -0
- package/dist/agent-ui.cjs.js.map +1 -0
- package/dist/agent-ui.esm.js +582 -0
- package/dist/agent-ui.esm.js.map +1 -0
- package/dist/api.cjs.js +63 -0
- package/dist/api.cjs.js.map +1 -0
- package/dist/api.esm.js +2 -0
- package/dist/api.esm.js.map +1 -0
- package/dist/astro/index.cjs.js +968 -4
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +968 -4
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +73 -45
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +73 -45
- package/dist/astro/server.mjs.map +1 -1
- package/dist/canvas.cjs.js +2919 -0
- package/dist/canvas.cjs.js.map +1 -0
- package/dist/canvas.esm.js +2866 -0
- package/dist/canvas.esm.js.map +1 -0
- package/dist/devtools.cjs.js +38 -6
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +38 -6
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +224 -20
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +224 -20
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +1 -1
- package/dist/guide/ai-compose.md +150 -0
- package/dist/guide/behaviors.md +2 -1
- package/dist/guide/collaborative-state.md +265 -0
- package/dist/guide/element-commands.md +36 -2
- package/dist/guide/forms-reference.md +5 -5
- package/dist/guide/forms.md +3 -2
- package/dist/guide/http.md +28 -1
- package/dist/guide/islands.md +240 -0
- package/dist/guide/recipes/code-editor.md +1 -1
- package/dist/guide/renderers/agent-ui.md +138 -0
- package/dist/guide/renderers/custom-targets.md +126 -0
- package/dist/guide/renderers/overview.md +120 -0
- package/dist/guide/renderers/react-native.md +128 -0
- package/dist/guide/renderers/terminal.md +306 -0
- package/dist/guide/renderers/three.md +332 -0
- package/dist/guide/renderers/write-once.md +137 -0
- package/dist/guide/resources.md +2 -0
- package/dist/guide/sandboxed-components.md +223 -0
- package/dist/guide/timers.md +32 -5
- package/dist/guide/typed-apis.md +442 -0
- package/dist/guide/ui/accordion.md +11 -0
- package/dist/guide/ui/combobox.md +112 -8
- package/dist/guide/ui/dialog.md +96 -20
- package/dist/guide/ui/menu.md +57 -5
- package/dist/guide/ui/overview.md +8 -4
- package/dist/guide/ui/popover.md +69 -9
- package/dist/guide/ui/select.md +54 -4
- package/dist/guide/ui/tabs.md +1 -1
- package/dist/guide/ui/tooltip.md +7 -5
- package/dist/guide/virtual-collections.md +3 -0
- package/dist/guide/widgets.md +90 -1
- package/dist/host.cjs.js +2181 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.esm.js +2146 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +3948 -586
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +584 -44
- package/dist/index.esm.js +3918 -586
- package/dist/index.esm.js.map +1 -1
- package/dist/primitives.cjs.js +143 -0
- package/dist/primitives.cjs.js.map +1 -0
- package/dist/primitives.esm.js +133 -0
- package/dist/primitives.esm.js.map +1 -0
- package/dist/react-native.cjs.js +248 -0
- package/dist/react-native.cjs.js.map +1 -0
- package/dist/react-native.esm.js +244 -0
- package/dist/react-native.esm.js.map +1 -0
- package/dist/replay.cjs.js +39 -0
- package/dist/replay.cjs.js.map +1 -0
- package/dist/replay.esm.js +2 -0
- package/dist/replay.esm.js.map +1 -0
- package/dist/sandbox-build.cjs.js +140 -0
- package/dist/sandbox-build.cjs.js.map +1 -0
- package/dist/sandbox-build.esm.js +137 -0
- package/dist/sandbox-build.esm.js.map +1 -0
- package/dist/sandbox-kernel.esm.js +212 -0
- package/dist/sandbox-kernel.esm.js.map +1 -0
- package/dist/sandbox-worker.cjs.js +857 -0
- package/dist/sandbox-worker.cjs.js.map +1 -0
- package/dist/sandbox-worker.esm.js +854 -0
- package/dist/sandbox-worker.esm.js.map +1 -0
- package/dist/sandbox.cjs.js +1425 -0
- package/dist/sandbox.cjs.js.map +1 -0
- package/dist/sandbox.esm.js +1406 -0
- package/dist/sandbox.esm.js.map +1 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/terminal.cjs.js +1314 -0
- package/dist/terminal.cjs.js.map +1 -0
- package/dist/terminal.esm.js +1304 -0
- package/dist/terminal.esm.js.map +1 -0
- package/dist/three.cjs.js +1111 -0
- package/dist/three.cjs.js.map +1 -0
- package/dist/three.esm.js +1079 -0
- package/dist/three.esm.js.map +1 -0
- package/dist/ui-combobox.cjs.js +256 -18
- package/dist/ui-combobox.cjs.js.map +1 -1
- package/dist/ui-combobox.esm.js +256 -18
- package/dist/ui-combobox.esm.js.map +1 -1
- package/dist/ui-menu.cjs.js +150 -16
- package/dist/ui-menu.cjs.js.map +1 -1
- package/dist/ui-menu.esm.js +151 -17
- package/dist/ui-menu.esm.js.map +1 -1
- package/dist/ui-select.cjs.js +95 -9
- package/dist/ui-select.cjs.js.map +1 -1
- package/dist/ui-select.esm.js +95 -9
- package/dist/ui-select.esm.js.map +1 -1
- package/dist/ui.cjs.js +679 -173
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +679 -175
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +968 -4
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +968 -4
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/worker-page.cjs.js +669 -0
- package/dist/worker-page.cjs.js.map +1 -0
- package/dist/worker-page.esm.js +665 -0
- package/dist/worker-page.esm.js.map +1 -0
- package/dist/worker.cjs.js +377 -0
- package/dist/worker.cjs.js.map +1 -0
- package/dist/worker.esm.js +374 -0
- package/dist/worker.esm.js.map +1 -0
- package/dist/yjs-testing.cjs.js +98 -0
- package/dist/yjs-testing.cjs.js.map +1 -0
- package/dist/yjs-testing.esm.js +77 -0
- package/dist/yjs-testing.esm.js.map +1 -0
- package/dist/yjs.cjs.js +923 -0
- package/dist/yjs.cjs.js.map +1 -0
- package/dist/yjs.esm.js +900 -0
- package/dist/yjs.esm.js.map +1 -0
- package/dist/zag.cjs.js +11 -1
- package/dist/zag.cjs.js.map +1 -1
- package/dist/zag.esm.js +11 -1
- package/dist/zag.esm.js.map +1 -1
- package/llms/ai.md +65 -0
- package/llms/collaboration.md +5 -0
- package/llms/graphics.md +10 -0
- package/llms/renderers.md +7 -0
- package/llms/replay.md +5 -0
- package/llms/sandbox.md +5 -0
- package/llms/typed-apis.md +5 -0
- package/llms/ui.md +10 -0
- package/llms-full.txt +444 -0
- package/llms.txt +37 -35
- package/package.json +139 -3
- package/src/agent-ui/mcp.ts +152 -0
- package/src/agent-ui/snapshot.ts +212 -0
- package/src/agent-ui.d.ts +106 -0
- package/src/agent-ui.ts +249 -0
- package/src/ai.d.ts +95 -2
- package/src/api.d.ts +303 -0
- package/src/api.ts +9 -0
- package/src/canvas/Canvas.ts +585 -0
- package/src/canvas/backend.ts +627 -0
- package/src/canvas/geometry.ts +187 -0
- package/src/canvas/gesture.ts +23 -0
- package/src/canvas/image.ts +197 -0
- package/src/canvas/kit.ts +130 -0
- package/src/canvas/paint.ts +260 -0
- package/src/canvas/pointer.ts +25 -0
- package/src/canvas/snap.ts +68 -0
- package/src/canvas/svg.ts +99 -0
- package/src/canvas.d.ts +373 -0
- package/src/canvas.ts +46 -0
- package/src/core/actions.ts +14 -3
- package/src/core/cell.ts +12 -4
- package/src/core/debug.ts +8 -3
- package/src/core/hooks.ts +16 -2
- package/src/core/hosts/collection.ts +1 -1
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +98 -45
- package/src/core/markers/portal.ts +4 -0
- package/src/core/registry.ts +4 -0
- package/src/core/runtime.ts +34 -25
- package/src/core/statics.ts +5 -5
- package/src/cycle/dom/EventDelegator.ts +37 -33
- package/src/cycle/dom/MainDOMSource.ts +16 -14
- package/src/cycle/dom/enrichEventStream.ts +13 -9
- package/src/cycle/dom/fromEvent.ts +6 -10
- package/src/cycle/dom/index.ts +11 -7
- package/src/cycle/dom/isolate.ts +13 -6
- package/src/cycle/dom/makeDOMDriver.ts +6 -2
- package/src/cycle/dom/utils.ts +9 -2
- package/src/cycle/run/adapt.ts +4 -12
- package/src/devtools.d.ts +9 -0
- package/src/devtools.ts +2 -1
- package/src/extra/ai/compose/catalog.ts +2 -1
- package/src/extra/ai/compose/decisions.ts +1 -2
- package/src/extra/ai/compose/generated.ts +234 -0
- package/src/extra/ai/compose/index.ts +6 -2
- package/src/extra/ai/compose/layout.ts +3 -1
- package/src/extra/ai/compose/llm.ts +7 -3
- package/src/extra/ai/compose/types.ts +2 -2
- package/src/extra/ai/transports/shared.ts +3 -1
- package/src/extra/api/contract.ts +485 -0
- package/src/extra/api/index.ts +26 -0
- package/src/extra/behaviors.ts +3 -3
- package/src/extra/controlled.ts +44 -0
- package/src/extra/copyAsTest.ts +22 -0
- package/src/extra/devtools.ts +5 -3
- package/src/extra/devtoolsActions.ts +8 -0
- package/src/extra/devtoolsNext.ts +1 -1
- package/src/extra/diagnostics/checks/actionLog.ts +8 -3
- package/src/extra/diagnostics/checks/behaviors.ts +25 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
- package/src/extra/diagnostics/checks/index.ts +1 -1
- package/src/extra/diagnostics/checks/next.ts +78 -4
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/checks/replies.ts +11 -7
- package/src/extra/diagnostics/checks/timers.ts +8 -2
- package/src/extra/diagnostics/checks/virtual.ts +11 -0
- package/src/extra/diagnostics/codes.ts +51 -1
- package/src/extra/elementCommands.ts +20 -4
- package/src/extra/fetchDriver.ts +4 -1
- package/src/extra/fetchEventSource.ts +6 -2
- package/src/extra/field.ts +29 -0
- package/src/extra/form.ts +42 -16
- package/src/extra/frame.ts +54 -0
- package/src/extra/keymap.ts +41 -0
- package/src/extra/pager.ts +2 -0
- package/src/extra/persist.ts +2 -1
- package/src/extra/pointer.ts +262 -0
- package/src/extra/processForm.ts +34 -1
- package/src/extra/replay/address.ts +49 -0
- package/src/extra/replay/entropy.ts +99 -0
- package/src/extra/replay/format.ts +263 -0
- package/src/extra/replay/index.ts +8 -0
- package/src/extra/replay/inert.ts +22 -0
- package/src/extra/replay/recorder.ts +247 -0
- package/src/extra/replay/replayer.ts +234 -0
- package/src/extra/replay/report.ts +126 -0
- package/src/extra/replay/schema.ts +52 -0
- package/src/extra/replay/version.ts +3 -0
- package/src/extra/router.ts +7 -3
- package/src/extra/selection.ts +2 -0
- package/src/extra/socketDriver.ts +51 -6
- package/src/extra/ssr.ts +128 -42
- package/src/extra/testing.ts +295 -39
- package/src/extra/timers.ts +96 -7
- package/src/extra/undo.ts +2 -0
- package/src/extra/virtual.ts +25 -2
- package/src/extra/widget.ts +106 -17
- package/src/host/actions.ts +383 -0
- package/src/host/delegation.ts +101 -0
- package/src/host/dom.ts +553 -0
- package/src/host/driver.ts +185 -0
- package/src/host/selector.ts +119 -0
- package/src/host/style.ts +51 -0
- package/src/host.d.ts +298 -0
- package/src/host.ts +46 -0
- package/src/index.d.ts +302 -38
- package/src/index.ts +15 -3
- package/src/pragma/blocks.ts +211 -0
- package/src/pragma/index.ts +44 -10
- package/src/primitives.d.ts +54 -0
- package/src/primitives.ts +136 -0
- package/src/react-native.d.ts +57 -0
- package/src/react-native.ts +285 -0
- package/src/react-peers.d.ts +21 -3
- package/src/replay.d.ts +185 -0
- package/src/replay.ts +6 -0
- package/src/sandbox/build.ts +119 -0
- package/src/sandbox/create.ts +187 -0
- package/src/sandbox/engine.ts +267 -0
- package/src/sandbox/generated.ts +195 -0
- package/src/sandbox/kernel.ts +180 -0
- package/src/sandbox/mirror.ts +269 -0
- package/src/sandbox/protocol.ts +128 -0
- package/src/sandbox/quickjs.ts +74 -0
- package/src/sandbox/ssr.ts +41 -0
- package/src/sandbox/widget.ts +60 -0
- package/src/sandbox/worker-entry.ts +59 -0
- package/src/sandbox/worker.ts +137 -0
- package/src/sandbox-build.d.ts +12 -0
- package/src/sandbox-build.ts +6 -0
- package/src/sandbox.d.ts +299 -0
- package/src/sandbox.ts +25 -0
- package/src/shared.ts +12 -0
- package/src/terminal/paint.ts +712 -0
- package/src/terminal.d.ts +167 -0
- package/src/terminal.ts +530 -0
- package/src/three/assets.ts +170 -0
- package/src/three/host.ts +36 -0
- package/src/three/scene.ts +217 -0
- package/src/three/target.ts +252 -0
- package/src/three.d.ts +221 -0
- package/src/three.ts +30 -0
- package/src/ui/accordion.ts +21 -7
- package/src/ui/dialog.ts +192 -90
- package/src/ui/disclosure.ts +2 -0
- package/src/ui/dom.ts +30 -0
- package/src/ui/layers.ts +132 -0
- package/src/ui/popover.ts +120 -21
- package/src/ui/shared.ts +98 -12
- package/src/ui/stack.ts +18 -0
- package/src/ui/tabs.ts +2 -0
- package/src/ui/toaster.ts +16 -15
- package/src/ui/tooltip.ts +24 -8
- package/src/ui/tooltips.ts +26 -14
- package/src/ui/zag/combobox.ts +180 -13
- package/src/ui/zag/menu.ts +97 -17
- package/src/ui/zag/select.ts +15 -4
- package/src/ui/zag/shared.ts +60 -8
- package/src/ui-combobox.d.ts +16 -1
- package/src/ui-menu.d.ts +22 -7
- package/src/ui-zag-types.d.ts +13 -5
- package/src/ui.d.ts +62 -7
- package/src/ui.ts +2 -2
- package/src/vite/api.ts +186 -0
- package/src/vite/compile.ts +280 -0
- package/src/vite/plugin.d.ts +34 -0
- package/src/vite/plugin.ts +78 -4
- package/src/vite/sandbox.ts +16 -0
- package/src/worker/page.ts +396 -0
- package/src/worker/protocol.ts +48 -0
- package/src/worker/target.ts +282 -0
- package/src/worker-page.d.ts +42 -0
- package/src/worker-page.ts +12 -0
- package/src/worker.d.ts +65 -0
- package/src/worker.ts +16 -0
- package/src/yjs/driver.ts +253 -0
- package/src/yjs/mapping.ts +389 -0
- package/src/yjs/shared.ts +138 -0
- package/src/yjs/testing.ts +91 -0
- package/src/yjs-testing.d.ts +34 -0
- package/src/yjs-testing.ts +7 -0
- package/src/yjs.d.ts +109 -0
- package/src/yjs.ts +18 -0
- package/src/zag.d.ts +2 -0
- 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
|
+
}
|