@cssints/tui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +205 -2
- package/dist/bomb.d.mts +86 -0
- package/dist/bomb.mjs +323 -0
- package/dist/index.d.mts +49 -0
- package/dist/index.mjs +88 -0
- package/dist/opentui.d.mts +66 -0
- package/dist/opentui.mjs +209 -0
- package/dist/system-DNKgrSBA.d.mts +116 -0
- package/dist/system.d.mts +2 -0
- package/dist/system.mjs +290 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexei Accio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,206 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cssints/tui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[cssints](../../README.md) in a terminal: the runtime of `cssints/tui` styles (states, the mouse pointer, the caret,
|
|
4
|
+
themes) and its adapters for [OpenTUI](https://opentui.com) and [@bomb.sh/tty](https://bomb.sh/docs/tty/api/). The styles are written with `cssints/tui`, which the cssints
|
|
5
|
+
Vite plugin compiles to prop objects; this package puts them on renderables. The design is in
|
|
6
|
+
[docs/research/tui.md](../../docs/research/tui.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// styles.ts
|
|
10
|
+
import * as css from "cssints/tui" with { type: "cssints" };
|
|
11
|
+
|
|
12
|
+
export const t = css.createTokens({
|
|
13
|
+
color: { $type: "color", bg: { $value: "#1e1e2e" }, accent: { $value: "#89b4fa" } },
|
|
14
|
+
});
|
|
15
|
+
css.createGlobalTheme(t, "light", { color: { bg: "#eff1f5", accent: "#1e66f5" } });
|
|
16
|
+
|
|
17
|
+
export const button = css.cn(
|
|
18
|
+
css.border("rounded", "#585b70"),
|
|
19
|
+
css.p(0, 1),
|
|
20
|
+
css.bg(t.color.bg),
|
|
21
|
+
css.cursor("pointer"),
|
|
22
|
+
css.hover(css.borderColor(t.color.accent)),
|
|
23
|
+
css.active(css.borderStyle("double")),
|
|
24
|
+
);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The plugin replaces `button` by:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
export const button = {
|
|
31
|
+
border: true,
|
|
32
|
+
borderStyle: "rounded",
|
|
33
|
+
borderColor: "#585b70",
|
|
34
|
+
paddingTop: 0,
|
|
35
|
+
paddingRight: 1,
|
|
36
|
+
paddingBottom: 0,
|
|
37
|
+
paddingLeft: 1,
|
|
38
|
+
backgroundColor: "#1e1e2e",
|
|
39
|
+
cursor: "pointer",
|
|
40
|
+
":hover": { borderColor: "#89b4fa" },
|
|
41
|
+
":active": { border: true, borderStyle: "double" },
|
|
42
|
+
themes: { light: { backgroundColor: "#eff1f5", ":hover": { borderColor: "#1e66f5" } } },
|
|
43
|
+
vars: { backgroundColor: "color-bg", ":hover": { borderColor: "color-accent" } }, // for a run-time theme
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { attach, setTheme, use } from "@cssints/tui/opentui";
|
|
49
|
+
|
|
50
|
+
<box {...use(button)}>
|
|
51
|
+
<text>Save</text>
|
|
52
|
+
</box>; // Solid or React
|
|
53
|
+
<text {...use(() => (active() ? tabActive : tab))}>Home</text>; // a style that changes
|
|
54
|
+
attach(new BoxRenderable(renderer, {}), button); // core
|
|
55
|
+
setTheme("light"); // every attached renderable
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Rules
|
|
59
|
+
|
|
60
|
+
- `attach(renderable, style)` sets the props of the style and keeps them in step with `:hover` (mouse `over` to
|
|
61
|
+
`out`), `:active` (`down` to `up`) and `:focus` (`focused` to `blurred`; a box needs `focusable: true`), in that
|
|
62
|
+
order of precedence, and with the theme of `setTheme`. Your own `onMouseOver` and the other handlers still run.
|
|
63
|
+
- `cursor` is the mouse pointer while over the renderable (`renderer.setMousePointer`, OSC 22), `"default"` again on out.
|
|
64
|
+
`caretShape`, `caretColor` and `caretAnimation` are the `cursorStyle` and `cursorColor` of an Input or a Textarea.
|
|
65
|
+
- `color` is OpenTUI's `fg`; `backgroundColor` is `backgroundColor` on a box and `bg` on a text; the text attributes
|
|
66
|
+
(`fontWeight`, `fontStyle`, `textDecoration`) are the bits of `attributes`. A prop the renderable does not have is
|
|
67
|
+
not set, so a text style on a box does nothing: put it on the `<text>`. On an editable or a select, `color` is
|
|
68
|
+
`textColor`.
|
|
69
|
+
- A focused renderable draws colours of its own: `focusedBorderColor` on a box (also while a descendant has the
|
|
70
|
+
focus), `focusedBackgroundColor` and `focusedTextColor` on an editable or a select. Each one follows the style's
|
|
71
|
+
colour in the current state, so a focused box draws its `:focus` border.
|
|
72
|
+
- `attach` on a renderable it has attached swaps the style: the states, the theme and the hooks stay, a prop only the
|
|
73
|
+
old style set goes back to the renderable's own value, and under the mouse the pointer follows the new `cursor`.
|
|
74
|
+
`use(() => style)` re-reads the accessor each time the ref runs: Solid runs a ref in a render effect, so a signal
|
|
75
|
+
the accessor reads swaps the style; React calls the new ref of each render.
|
|
76
|
+
- A text node (`<span>`, `<a>`, `<b>`) takes the props and the theme. It has no mouse events and no focus, so it has
|
|
77
|
+
no states: put `:hover` on the `<text>` around it.
|
|
78
|
+
- `resolve(style, state, theme)` of `@cssints/tui` is the renderer-neutral part: the props of a style in a state and a
|
|
79
|
+
theme, for another adapter.
|
|
80
|
+
|
|
81
|
+
## The system theme
|
|
82
|
+
|
|
83
|
+
A theme computed at run time from the terminal's own colours: each colour token is a role, a hue source (an ANSI
|
|
84
|
+
colour, or the terminal's `fg`/`bg`) and contrast rules against the tokens it sits on.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// theme.ts: run time, not a cssints module
|
|
88
|
+
import { createSystemTheme } from "@cssints/tui/system";
|
|
89
|
+
|
|
90
|
+
import { t } from "./styles.ts";
|
|
91
|
+
|
|
92
|
+
export const system = createSystemTheme(
|
|
93
|
+
t,
|
|
94
|
+
{
|
|
95
|
+
color: {
|
|
96
|
+
bg: { surface: true }, // the terminal's background
|
|
97
|
+
card: { surface: true, on: [t.color.bg], contrast: 1.1 }, // a panel 1.1:1 from it
|
|
98
|
+
text: { hue: "fg", on: [t.color.bg, t.color.card], contrast: 7 },
|
|
99
|
+
accent: { hue: "blue", on: [t.color.bg, t.color.card], contrast: 3 },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ hues: { blue: "#89b4fa" }, themes: { light: "light" } },
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// OpenTUI
|
|
108
|
+
import { followSystemTheme } from "@cssints/tui/opentui";
|
|
109
|
+
|
|
110
|
+
const renderer = await createCliRenderer();
|
|
111
|
+
await followSystemTheme(renderer, system); // the palette, or 100 ms; sets the theme "system"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **A role.** `hue` (default `fg`, `bg` for a surface), `on` (tokens with a role), `contrast` (WCAG 2). A text role
|
|
115
|
+
keeps its source colour when it meets every rule, else takes the lightness of the strictest one: the reversed
|
|
116
|
+
WCAG ratio, bisected in OKHSL at the source's hue and saturation (the saturation falls off toward black and white),
|
|
117
|
+
the OKLCH chroma capped at the source's, so a colour can move but never get more vivid. A `surface` sits at exactly
|
|
118
|
+
`contrast` from its first `on`, toward the foreground. `ansi`: the index (or `"default"`) to draw when the terminal
|
|
119
|
+
reports nothing.
|
|
120
|
+
- **What the terminal reports.** Its background, foreground and ANSI colours: every role from its colour, placed for
|
|
121
|
+
the real background. Its background only: the app's hues (`options.hues`). Nothing: on OpenTUI, ANSI indices and the
|
|
122
|
+
terminal's default colours (`ansi:4`, `default`, which the adapter turns into `RGBA.fromIndex` and
|
|
123
|
+
`RGBA.defaultForeground`); on bomb.sh (no indexed colour) the static theme of the appearance.
|
|
124
|
+
- **Light or dark:** the background (darker than the foreground), else the colour-scheme report (`CSI ? 997 ; 1|2 n`,
|
|
125
|
+
or OpenTUI's `themeMode`), else `COLORFGBG`, else dark. `options.themes` names the static theme under each: a token
|
|
126
|
+
without a role keeps its value.
|
|
127
|
+
- **Changes.** OpenTUI turns on mode 2031 and emits `theme_mode` and `palette`; the follower rebuilds on both. On
|
|
128
|
+
bomb.sh the follower writes `CSI ? 2031 h` and queries again on a notification; `stop()` turns it off.
|
|
129
|
+
- `theme.solve(report)` gives the values of a report (`{ fg, bg, ansi, scheme }`) without applying them;
|
|
130
|
+
`defineTheme(name, values, base)` of `@cssints/tui` is the run-time table it fills (a colour per token name,
|
|
131
|
+
`color-bg` for `var(--color-bg)`), for a theme of your own.
|
|
132
|
+
|
|
133
|
+
## @bomb.sh/tty
|
|
134
|
+
|
|
135
|
+
bomb.sh draws a frame from a list of ops (`open`, `text`, `close`) and returns the pointer events of that frame. The
|
|
136
|
+
adapter `@cssints/tui/bomb` turns a style, in the state of its element id, into the options of `open()` and `text()`,
|
|
137
|
+
and its tracker keeps the states across frames:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { close, createTerm } from "@bomb.sh/tty";
|
|
141
|
+
import { createTracker, setTheme } from "@cssints/tui/bomb";
|
|
142
|
+
|
|
143
|
+
const term = await createTerm({ width: 80, height: 24 });
|
|
144
|
+
const ui = createTracker();
|
|
145
|
+
const build = () => [
|
|
146
|
+
ui.open("save", button), // open("save", options of `button` in the state of "save")
|
|
147
|
+
ui.text("Save", label, "save"), // text("Save", options of `label` in the state of "save")
|
|
148
|
+
close(),
|
|
149
|
+
];
|
|
150
|
+
// each mouse event: the pointer is { x, y, down }
|
|
151
|
+
process.stdout.write(ui.render(term, build, { pointer }).output);
|
|
152
|
+
setTheme("light"); // the next frame takes the light values
|
|
153
|
+
process.stdout.write(ui.reset()); // on exit: the default pointer and caret
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `ui.render(term, build, options)` renders `build()`, reads the `pointerenter`/`pointerleave` events and, when a state
|
|
157
|
+
changed, renders `build()` again, so a hover shows in the same frame. `output` holds both frames and the escape
|
|
158
|
+
bytes of the pointer and the caret. The events of both frames are in `events` (`pointerclick` for a click).
|
|
159
|
+
- `:hover` is on from `pointerenter` to `pointerleave` (an element and its ancestors, as Clay reports them); `:active`
|
|
160
|
+
is on from a press (`pointer.down` becomes true) over the element to the release or a leave; `:focus` is the id given
|
|
161
|
+
to `ui.focus(id)` (bomb.sh has no focus of its own).
|
|
162
|
+
- `cursor` is the mouse pointer (OSC 22) of the innermost hovered element that has one, `default` when there is none.
|
|
163
|
+
The caret of the focused element is DECSCUSR (`caretShape`, `caretAnimation`) and OSC 12 (`caretColor`); its place
|
|
164
|
+
is the `caret` option of `text()`, which is yours. `ui.reset()` gives the terminal its pointer and caret back.
|
|
165
|
+
- `openProps(props)` and `textProps(props)` are the plain mapping of `resolve()` (no tracker): a number is `fixed(n)`,
|
|
166
|
+
`N%` is `percent`, `auto` is `fit`, `flexGrow` is `grow` on each axis with no size, `min*`/`max*` are the bounds of
|
|
167
|
+
`fit` and `grow`, `flexDirection` is `direction`, `justifyContent` and `alignItems` are `alignX` and `alignY` (by the
|
|
168
|
+
direction), `borderStyle: "rounded"` is a `cornerRadius` of 1, `overflow` is `clip`, `position: "absolute"` is
|
|
169
|
+
`floating` on the parent, `display: "none"` is a box of size 0 that clips. The full table, and what does not map, is
|
|
170
|
+
in docs/research/tui.md.
|
|
171
|
+
- **What bomb.sh cannot draw:** `double` and `heavy` borders are a single line (bomb.sh has one line set and round
|
|
172
|
+
corners), margins (wrap the element in a parent with padding), `flexShrink`, `flexBasis`, `flexWrap`, the
|
|
173
|
+
`-reverse` directions (drawn forward), `space-*`, `stretch` and `baseline` alignment, `alignSelf`, `opacity`,
|
|
174
|
+
percentages of padding and gap, scroll offsets (`overflow: scroll` clips), `zIndex` without `position: absolute`.
|
|
175
|
+
They are left out at run time; the build cannot tell, since the same style may go to OpenTUI.
|
|
176
|
+
- Transitions are not a cssints/tui member: give bomb.sh's `transition` as the last argument of `ui.open` and render
|
|
177
|
+
again while `animating`.
|
|
178
|
+
- The system theme: `followSystemTheme(system, { write })` writes the queries (OSC 10, 11, 4, `CSI ? 996 n`, mode
|
|
179
|
+
2031); pass each chunk of stdin through `follow.feed(bytes)`, which takes the replies out and returns the rest for
|
|
180
|
+
`input.scan()` (bomb.sh's parser does not report OSC replies). `await follow.ready`: the replies or 100 ms.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const follow = followSystemTheme(system, { write: (s) => process.stdout.write(s) });
|
|
184
|
+
process.stdin.on("data", (bytes) => dispatch(input.scan(follow.feed(bytes)).events));
|
|
185
|
+
await follow.ready;
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Limits
|
|
189
|
+
|
|
190
|
+
No terminal HMR, no inheritance of text styles, no per-side borders or titles (the renderable's own options), no
|
|
191
|
+
units or `calc()`. See "Out of scope" in docs/research/tui.md.
|
|
192
|
+
|
|
193
|
+
## Credits
|
|
194
|
+
|
|
195
|
+
The adapter is for [OpenTUI](https://opentui.com) (MIT). No code of it is copied.
|
|
196
|
+
|
|
197
|
+
## Tests
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
bun run test # node test/system.ts (the solver on Gruvbox dark, Catppuccin Latte and Nord, the fallbacks, the replies
|
|
201
|
+
# as bytes, the bomb.sh follower), node test/tui.mjs (the plugin: dev transform, SSR build, errors),
|
|
202
|
+
# bun test/render.ts (OpenTUI's test renderer; the system theme on a fake terminal that answers OSC 4/10/11),
|
|
203
|
+
# node test/bomb.ts (bomb.sh's renderer at 30x8, the cells read back from its output)
|
|
204
|
+
bun run example # examples/bomb.ts: a list with hover, press and click, `t` swaps the theme, `q` quits
|
|
205
|
+
bun run typecheck # src and test/types/tui.ts (what the types reject)
|
|
206
|
+
```
|
package/dist/bomb.d.mts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TuiState, onTheme, resolve, setTheme, theme } from "./index.mjs";
|
|
2
|
+
import { h as SystemTheme } from "./system-DNKgrSBA.mjs";
|
|
3
|
+
import { Op, OpenElement, RenderOptions, RenderResult, Term, Text } from "@bomb.sh/tty";
|
|
4
|
+
import { TuiProps, TuiShape } from "cssints/tui";
|
|
5
|
+
//#region src/bomb-system.d.ts
|
|
6
|
+
/** A system theme fed by the bytes of stdin. */
|
|
7
|
+
interface SystemFollower {
|
|
8
|
+
/** Takes the terminal's replies out of a chunk of stdin and gives back the rest, for `input.scan()`. */
|
|
9
|
+
feed(bytes: Uint8Array): Uint8Array;
|
|
10
|
+
/** Settles when the terminal answered or `wait` ms passed, the fallback in place; a late reply still applies. */
|
|
11
|
+
ready: Promise<void>;
|
|
12
|
+
/** Turns the change notifications off and stops the timer. */
|
|
13
|
+
stop(): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Follows the terminal's colours: writes the queries (OSC 10, 11, 4, `CSI ? 996 n`) and turns on mode 2031, sets the
|
|
17
|
+
* system theme (`setTheme`), and rebuilds it from the replies `feed` finds; on a change notification it queries again.
|
|
18
|
+
*
|
|
19
|
+
* @param {SystemTheme} theme the theme of `createSystemTheme`
|
|
20
|
+
* @param {object} options `write` (the bytes to the terminal), `wait` (ms, default 100), `colorfgbg` (default: the environment's)
|
|
21
|
+
* @returns {SystemFollower} `feed`, `ready` and `stop`
|
|
22
|
+
*/
|
|
23
|
+
export declare const followSystemTheme: (theme: SystemTheme, options: {
|
|
24
|
+
write: (text: string) => void;
|
|
25
|
+
wait?: number;
|
|
26
|
+
colorfgbg?: string;
|
|
27
|
+
}) => SystemFollower;
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/bomb.d.ts
|
|
30
|
+
/** The options of `open(id, options)`. */
|
|
31
|
+
export type OpenProps = Omit<OpenElement, "directive" | "id">;
|
|
32
|
+
/** The options of `text(content, options)`. */
|
|
33
|
+
export type TextProps = Omit<Text, "directive" | "content">;
|
|
34
|
+
/**
|
|
35
|
+
* A colour of a style (`#rrggbb` or `#rrggbbaa`) as bomb.sh packs it.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} hex a colour of `resolve()`
|
|
38
|
+
* @returns {number} `rgba(r, g, b, a)`
|
|
39
|
+
*/
|
|
40
|
+
export declare const rgbaOf: (hex: string) => number;
|
|
41
|
+
/**
|
|
42
|
+
* The options of `open()` for props of `resolve()`: sizes (a number is `fixed`, `N%` is `percent`, `auto` is `fit`,
|
|
43
|
+
* `flexGrow` is `grow` on each axis without a size), `flexDirection`, padding, gap, `justifyContent` and `alignItems`
|
|
44
|
+
* (`alignX`, `alignY`), `backgroundColor`, the border (one cell a side; `rounded` gives round corners), `overflow`
|
|
45
|
+
* (clip) and `position: absolute` (floating). What bomb.sh has no option for is left out (docs/research/tui.md).
|
|
46
|
+
*
|
|
47
|
+
* @param {TuiProps} p the props of `resolve()`
|
|
48
|
+
* @returns {OpenProps} the options of `open(id, options)`
|
|
49
|
+
*/
|
|
50
|
+
export declare const openProps: (p: TuiProps) => OpenProps;
|
|
51
|
+
/**
|
|
52
|
+
* The options of `text()` for props of `resolve()`: `color`, `backgroundColor` (`bg`) and the text attributes
|
|
53
|
+
* (`bold`, `dim`, `italic`, `underline`, `strikethrough`) as the bits of `attrs`.
|
|
54
|
+
*
|
|
55
|
+
* @param {TuiProps} p the props of `resolve()`
|
|
56
|
+
* @returns {TextProps} the options of `text(content, options)`
|
|
57
|
+
*/
|
|
58
|
+
export declare const textProps: (p: TuiProps) => TextProps;
|
|
59
|
+
/** The states of the elements of a frame, fed by the pointer events of `term.render()`. */
|
|
60
|
+
export interface Tracker {
|
|
61
|
+
/** The states of element `id`: `hover` from `pointerenter` to `pointerleave`, `active` while pressed, `focus`. */
|
|
62
|
+
state(id: string): TuiState;
|
|
63
|
+
/** `open(id, ...)` with the options of `style` in the state of `id`; `extra` is laid over them (a `transition`). */
|
|
64
|
+
open(id: string, style: TuiShape, extra?: OpenProps): OpenElement;
|
|
65
|
+
/** `text(content, ...)` with the options of `style` in the state of element `id` (none: the base style). */
|
|
66
|
+
text(content: string, style: TuiShape, id?: string, extra?: TextProps): Text;
|
|
67
|
+
/** Gives element `id` the `:focus` state and its caret style (`undefined`: none). */
|
|
68
|
+
focus(id: string | undefined): void;
|
|
69
|
+
/**
|
|
70
|
+
* Renders `build()` with `options`, reads its pointer events and, when a state changed, renders `build()` again,
|
|
71
|
+
* so a hover shows in the same frame. `output` holds both, and the OSC 22 pointer and caret bytes that changed.
|
|
72
|
+
*/
|
|
73
|
+
render(term: Term, build: () => Op[], options?: RenderOptions): RenderResult;
|
|
74
|
+
/** The bytes that give the terminal back its pointer and caret, to write on exit. */
|
|
75
|
+
reset(): Uint8Array;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A tracker of element states for one `Term`: build each frame with its `open` and `text`, and render with its
|
|
79
|
+
* `render`. `:hover` follows `pointerenter`/`pointerleave` (an element and its ancestors), `:active` holds from a
|
|
80
|
+
* pointer press over the element to the release (`pointerclick`) or a leave, `:focus` is the id given to `focus`.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Tracker} the tracker
|
|
83
|
+
*/
|
|
84
|
+
export declare const createTracker: () => Tracker;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { type SystemFollower, type TuiState, onTheme, resolve, setTheme, theme };
|
package/dist/bomb.mjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { onTheme, resolve, setTheme, theme } from "./index.mjs";
|
|
2
|
+
import { NOTIFY, QUERY, UNNOTIFY, scan } from "./system.mjs";
|
|
3
|
+
import { fit, fixed, grow, open, percent, rgba, text } from "@bomb.sh/tty";
|
|
4
|
+
//#region src/bomb-system.ts
|
|
5
|
+
const binary = (bytes) => {
|
|
6
|
+
let s = "";
|
|
7
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
8
|
+
return s;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Follows the terminal's colours: writes the queries (OSC 10, 11, 4, `CSI ? 996 n`) and turns on mode 2031, sets the
|
|
12
|
+
* system theme (`setTheme`), and rebuilds it from the replies `feed` finds; on a change notification it queries again.
|
|
13
|
+
*
|
|
14
|
+
* @param {SystemTheme} theme the theme of `createSystemTheme`
|
|
15
|
+
* @param {object} options `write` (the bytes to the terminal), `wait` (ms, default 100), `colorfgbg` (default: the environment's)
|
|
16
|
+
* @returns {SystemFollower} `feed`, `ready` and `stop`
|
|
17
|
+
*/
|
|
18
|
+
const followSystemTheme = (theme, options) => {
|
|
19
|
+
const report = { ansi: [] };
|
|
20
|
+
const apply = () => theme.apply(report, { colorfgbg: options.colorfgbg });
|
|
21
|
+
let tail = "";
|
|
22
|
+
let done = false;
|
|
23
|
+
const { promise: ready, resolve: settle } = Promise.withResolvers();
|
|
24
|
+
const finish = () => {
|
|
25
|
+
if (done) return;
|
|
26
|
+
done = true;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
apply();
|
|
29
|
+
settle();
|
|
30
|
+
};
|
|
31
|
+
const timer = setTimeout(finish, options.wait ?? 100);
|
|
32
|
+
apply();
|
|
33
|
+
setTheme(theme.name);
|
|
34
|
+
options.write(QUERY + NOTIFY);
|
|
35
|
+
return {
|
|
36
|
+
ready,
|
|
37
|
+
feed: (bytes) => {
|
|
38
|
+
const { replies, rest, tail: cut } = scan(tail + binary(bytes));
|
|
39
|
+
tail = cut;
|
|
40
|
+
const found = replies.fg || replies.bg || replies.scheme || replies.ansi.size;
|
|
41
|
+
if (replies.fg) report.fg = replies.fg;
|
|
42
|
+
if (replies.bg) report.bg = replies.bg;
|
|
43
|
+
for (const [i, hex] of replies.ansi) report.ansi[i] = hex;
|
|
44
|
+
if (replies.scheme) {
|
|
45
|
+
report.scheme = replies.scheme;
|
|
46
|
+
if (done) options.write(QUERY);
|
|
47
|
+
}
|
|
48
|
+
const complete = report.fg && report.bg && report.ansi.filter(Boolean).length === 16;
|
|
49
|
+
if (found && done) apply();
|
|
50
|
+
else if (complete) finish();
|
|
51
|
+
return Uint8Array.from(rest, (ch) => ch.charCodeAt(0));
|
|
52
|
+
},
|
|
53
|
+
stop: () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
options.write(UNNOTIFY);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/bomb.ts
|
|
61
|
+
const ATTRS = {
|
|
62
|
+
bold: 1,
|
|
63
|
+
dim: 2,
|
|
64
|
+
italic: 4,
|
|
65
|
+
underline: 8,
|
|
66
|
+
strikethrough: 64
|
|
67
|
+
};
|
|
68
|
+
const START = {
|
|
69
|
+
"flex-start": 0,
|
|
70
|
+
center: 1,
|
|
71
|
+
"flex-end": 2
|
|
72
|
+
};
|
|
73
|
+
const X = [
|
|
74
|
+
"left",
|
|
75
|
+
"center",
|
|
76
|
+
"right"
|
|
77
|
+
];
|
|
78
|
+
const Y = [
|
|
79
|
+
"top",
|
|
80
|
+
"center",
|
|
81
|
+
"bottom"
|
|
82
|
+
];
|
|
83
|
+
const CARET = {
|
|
84
|
+
auto: 0,
|
|
85
|
+
block: 1,
|
|
86
|
+
underscore: 3,
|
|
87
|
+
bar: 5
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* A colour of a style (`#rrggbb` or `#rrggbbaa`) as bomb.sh packs it.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} hex a colour of `resolve()`
|
|
93
|
+
* @returns {number} `rgba(r, g, b, a)`
|
|
94
|
+
*/
|
|
95
|
+
const rgbaOf = (hex) => {
|
|
96
|
+
const n = (i) => Number.parseInt(hex.slice(i, i + 2), 16);
|
|
97
|
+
return rgba(n(1), n(3), n(5), hex.length > 7 ? n(7) : 255);
|
|
98
|
+
};
|
|
99
|
+
const bound = (v) => typeof v === "number" ? v : 0;
|
|
100
|
+
const axis = (v, min, max, grows) => {
|
|
101
|
+
if (typeof v === "number") return fixed(v);
|
|
102
|
+
if (v !== void 0 && v !== "auto") return percent(Number.parseFloat(v) / 100);
|
|
103
|
+
if (grows) return grow(bound(min), bound(max));
|
|
104
|
+
if (v === "auto" || bound(min) || bound(max)) return fit(bound(min), bound(max));
|
|
105
|
+
};
|
|
106
|
+
const align = (v) => v !== void 0 && Object.hasOwn(START, v) ? START[v] : void 0;
|
|
107
|
+
/**
|
|
108
|
+
* The options of `open()` for props of `resolve()`: sizes (a number is `fixed`, `N%` is `percent`, `auto` is `fit`,
|
|
109
|
+
* `flexGrow` is `grow` on each axis without a size), `flexDirection`, padding, gap, `justifyContent` and `alignItems`
|
|
110
|
+
* (`alignX`, `alignY`), `backgroundColor`, the border (one cell a side; `rounded` gives round corners), `overflow`
|
|
111
|
+
* (clip) and `position: absolute` (floating). What bomb.sh has no option for is left out (docs/research/tui.md).
|
|
112
|
+
*
|
|
113
|
+
* @param {TuiProps} p the props of `resolve()`
|
|
114
|
+
* @returns {OpenProps} the options of `open(id, options)`
|
|
115
|
+
*/
|
|
116
|
+
const openProps = (p) => {
|
|
117
|
+
const out = {};
|
|
118
|
+
const layout = {};
|
|
119
|
+
const hidden = p.visible === false;
|
|
120
|
+
const grows = (p.flexGrow ?? 0) > 0;
|
|
121
|
+
const width = hidden ? fixed(0) : axis(p.width, p.minWidth, p.maxWidth, grows);
|
|
122
|
+
const height = hidden ? fixed(0) : axis(p.height, p.minHeight, p.maxHeight, grows);
|
|
123
|
+
if (width) layout.width = width;
|
|
124
|
+
if (height) layout.height = height;
|
|
125
|
+
const ttb = p.flexDirection === "column" || p.flexDirection === "column-reverse";
|
|
126
|
+
if (p.flexDirection) layout.direction = ttb ? "ttb" : "ltr";
|
|
127
|
+
const padding = {};
|
|
128
|
+
for (const [side, v] of [
|
|
129
|
+
["top", p.paddingTop],
|
|
130
|
+
["right", p.paddingRight],
|
|
131
|
+
["bottom", p.paddingBottom],
|
|
132
|
+
["left", p.paddingLeft]
|
|
133
|
+
]) if (typeof v === "number") padding[side] = v;
|
|
134
|
+
if (Object.keys(padding).length) layout.padding = padding;
|
|
135
|
+
const gap = (ttb ? p.rowGap : p.columnGap) ?? p.gap;
|
|
136
|
+
if (typeof gap === "number") layout.gap = gap;
|
|
137
|
+
const main = align(p.justifyContent);
|
|
138
|
+
const cross = align(p.alignItems);
|
|
139
|
+
const x = (ttb ? cross : main) ?? (p.textAlign ? X.indexOf(p.textAlign) : void 0);
|
|
140
|
+
const y = ttb ? main : cross;
|
|
141
|
+
if (x !== void 0) layout.alignX = X[x];
|
|
142
|
+
if (y !== void 0) layout.alignY = Y[y];
|
|
143
|
+
if (Object.keys(layout).length) out.layout = layout;
|
|
144
|
+
if (p.backgroundColor) out.bg = rgbaOf(p.backgroundColor);
|
|
145
|
+
if (p.border && !hidden) {
|
|
146
|
+
out.border = {
|
|
147
|
+
color: rgbaOf(p.borderColor ?? p.color ?? "#ffffff"),
|
|
148
|
+
top: 1,
|
|
149
|
+
right: 1,
|
|
150
|
+
bottom: 1,
|
|
151
|
+
left: 1
|
|
152
|
+
};
|
|
153
|
+
if (p.borderStyle === "rounded") out.cornerRadius = {
|
|
154
|
+
tl: 1,
|
|
155
|
+
tr: 1,
|
|
156
|
+
bl: 1,
|
|
157
|
+
br: 1
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (hidden || p.overflow === "hidden" || p.overflow === "scroll") out.clip = {
|
|
161
|
+
horizontal: true,
|
|
162
|
+
vertical: true
|
|
163
|
+
};
|
|
164
|
+
if (p.position === "absolute") {
|
|
165
|
+
const right = p.left === void 0 && typeof p.right === "number";
|
|
166
|
+
const bottom = p.top === void 0 && typeof p.bottom === "number";
|
|
167
|
+
const point = `${right ? "right" : "left"}-${bottom ? "bottom" : "top"}`;
|
|
168
|
+
out.floating = {
|
|
169
|
+
attachTo: "parent",
|
|
170
|
+
attachPoints: {
|
|
171
|
+
element: point,
|
|
172
|
+
parent: point
|
|
173
|
+
},
|
|
174
|
+
x: right ? -bound(p.right) : bound(p.left),
|
|
175
|
+
y: bottom ? -bound(p.bottom) : bound(p.top)
|
|
176
|
+
};
|
|
177
|
+
if (p.zIndex !== void 0) out.floating.zIndex = p.zIndex;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The options of `text()` for props of `resolve()`: `color`, `backgroundColor` (`bg`) and the text attributes
|
|
183
|
+
* (`bold`, `dim`, `italic`, `underline`, `strikethrough`) as the bits of `attrs`.
|
|
184
|
+
*
|
|
185
|
+
* @param {TuiProps} p the props of `resolve()`
|
|
186
|
+
* @returns {TextProps} the options of `text(content, options)`
|
|
187
|
+
*/
|
|
188
|
+
const textProps = (p) => {
|
|
189
|
+
const out = {};
|
|
190
|
+
if (p.color) out.color = rgbaOf(p.color);
|
|
191
|
+
if (p.backgroundColor) out.bg = rgbaOf(p.backgroundColor);
|
|
192
|
+
let attrs;
|
|
193
|
+
for (const [k, bit] of Object.entries(ATTRS)) if (Object.hasOwn(p, k)) attrs = (attrs ?? 0) | (p[k] ? bit : 0);
|
|
194
|
+
if (attrs !== void 0) out.attrs = attrs;
|
|
195
|
+
return out;
|
|
196
|
+
};
|
|
197
|
+
const encoder = new TextEncoder();
|
|
198
|
+
const osc = (body) => `\x1b]${body}\x1b\\`;
|
|
199
|
+
const caretOf = (p) => {
|
|
200
|
+
let out = "";
|
|
201
|
+
if (p.caretShape || p.caretAnimation) {
|
|
202
|
+
const n = CARET[p.caretShape ?? "auto"];
|
|
203
|
+
out += `\x1b[${n && p.caretAnimation === "manual" ? n + 1 : n} q`;
|
|
204
|
+
}
|
|
205
|
+
if (p.caretColor) out += osc(`12;${p.caretColor.slice(0, 7)}`);
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
const join = (parts) => {
|
|
209
|
+
const bytes = parts.map((b) => typeof b === "string" ? encoder.encode(b) : b);
|
|
210
|
+
const out = new Uint8Array(bytes.reduce((n, b) => n + b.length, 0));
|
|
211
|
+
let at = 0;
|
|
212
|
+
for (const b of bytes) {
|
|
213
|
+
out.set(b, at);
|
|
214
|
+
at += b.length;
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
};
|
|
218
|
+
/**
|
|
219
|
+
* A tracker of element states for one `Term`: build each frame with its `open` and `text`, and render with its
|
|
220
|
+
* `render`. `:hover` follows `pointerenter`/`pointerleave` (an element and its ancestors), `:active` holds from a
|
|
221
|
+
* pointer press over the element to the release (`pointerclick`) or a leave, `:focus` is the id given to `focus`.
|
|
222
|
+
*
|
|
223
|
+
* @returns {Tracker} the tracker
|
|
224
|
+
*/
|
|
225
|
+
const createTracker = () => {
|
|
226
|
+
const hovered = /* @__PURE__ */ new Set();
|
|
227
|
+
const active = /* @__PURE__ */ new Set();
|
|
228
|
+
const styles = /* @__PURE__ */ new Map();
|
|
229
|
+
let focused;
|
|
230
|
+
let down = false;
|
|
231
|
+
let pointer = "default";
|
|
232
|
+
let caret = "";
|
|
233
|
+
const state = (id) => ({
|
|
234
|
+
hover: hovered.has(id),
|
|
235
|
+
active: active.has(id),
|
|
236
|
+
focus: focused === id
|
|
237
|
+
});
|
|
238
|
+
const update = (events, press) => {
|
|
239
|
+
let changed = false;
|
|
240
|
+
for (const e of events) if (e.type === "pointerenter") {
|
|
241
|
+
changed ||= !hovered.has(e.id);
|
|
242
|
+
hovered.add(e.id);
|
|
243
|
+
} else {
|
|
244
|
+
if (e.type === "pointerleave") changed = hovered.delete(e.id) || changed;
|
|
245
|
+
changed = active.delete(e.id) || changed;
|
|
246
|
+
}
|
|
247
|
+
if (press && !down) for (const id of hovered) {
|
|
248
|
+
active.add(id);
|
|
249
|
+
changed = true;
|
|
250
|
+
}
|
|
251
|
+
if (!press && active.size) {
|
|
252
|
+
active.clear();
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
down = press;
|
|
256
|
+
return changed;
|
|
257
|
+
};
|
|
258
|
+
const escapes = () => {
|
|
259
|
+
let out = "";
|
|
260
|
+
let next = "default";
|
|
261
|
+
for (const id of [...hovered].reverse()) {
|
|
262
|
+
const style = styles.get(id);
|
|
263
|
+
const cursor = style && resolve(style, state(id)).cursor;
|
|
264
|
+
if (cursor) {
|
|
265
|
+
next = cursor;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (next !== pointer) out += osc(`22;${next}`);
|
|
270
|
+
pointer = next;
|
|
271
|
+
const style = focused === void 0 ? void 0 : styles.get(focused);
|
|
272
|
+
const nextCaret = style && focused !== void 0 ? caretOf(resolve(style, state(focused))) : "";
|
|
273
|
+
if (nextCaret !== caret) out += nextCaret || "\x1B[0 q" + osc("112");
|
|
274
|
+
caret = nextCaret;
|
|
275
|
+
return out;
|
|
276
|
+
};
|
|
277
|
+
return {
|
|
278
|
+
state,
|
|
279
|
+
open: (id, style, extra) => {
|
|
280
|
+
styles.set(id, style);
|
|
281
|
+
return open(id, {
|
|
282
|
+
...openProps(resolve(style, state(id))),
|
|
283
|
+
...extra
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
text: (content, style, id, extra) => text(content, {
|
|
287
|
+
...textProps(resolve(style, id === void 0 ? {} : state(id))),
|
|
288
|
+
...extra
|
|
289
|
+
}),
|
|
290
|
+
focus: (id) => {
|
|
291
|
+
focused = id;
|
|
292
|
+
},
|
|
293
|
+
render: (term, build, options) => {
|
|
294
|
+
const press = options?.pointer?.down ?? false;
|
|
295
|
+
styles.clear();
|
|
296
|
+
const first = term.render(build(), options);
|
|
297
|
+
if (!update(first.events, press)) return {
|
|
298
|
+
...first,
|
|
299
|
+
output: join([first.output, escapes()])
|
|
300
|
+
};
|
|
301
|
+
const output = first.output.slice();
|
|
302
|
+
styles.clear();
|
|
303
|
+
const again = term.render(build(), options);
|
|
304
|
+
update(again.events, press);
|
|
305
|
+
return {
|
|
306
|
+
...again,
|
|
307
|
+
output: join([
|
|
308
|
+
output,
|
|
309
|
+
again.output,
|
|
310
|
+
escapes()
|
|
311
|
+
]),
|
|
312
|
+
events: [...first.events, ...again.events]
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
reset: () => join([
|
|
316
|
+
osc("22;default"),
|
|
317
|
+
"\x1B[0 q",
|
|
318
|
+
osc("112")
|
|
319
|
+
])
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
323
|
+
export { createTracker, followSystemTheme, onTheme, openProps, resolve, rgbaOf, setTheme, textProps, theme };
|