@flowtty/core 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
@@ -0,0 +1,40 @@
1
+ //#region src/keys.ts
2
+ /**
3
+ * Every multi-character key name a backend produces. A printable key's name is
4
+ * the character itself (`' '`, `':'`, `'a'`) — there is no `'space'`, `'enter'`
5
+ * or `'colon'`. Sequences the key parser does not recognize surface under a
6
+ * `csi-…` name, which is deliberately not listed here.
7
+ */
8
+ const NAMED_KEYS = [
9
+ "return",
10
+ "escape",
11
+ "tab",
12
+ "backspace",
13
+ "delete",
14
+ "insert",
15
+ "up",
16
+ "down",
17
+ "left",
18
+ "right",
19
+ "home",
20
+ "end",
21
+ "pageup",
22
+ "pagedown",
23
+ "f1",
24
+ "f2",
25
+ "f3",
26
+ "f4",
27
+ "f5",
28
+ "f6",
29
+ "f7",
30
+ "f8",
31
+ "f9",
32
+ "f10",
33
+ "f11",
34
+ "f12",
35
+ "paste",
36
+ "wheelup",
37
+ "wheeldown"
38
+ ];
39
+ //#endregion
40
+ export { NAMED_KEYS as t };
@@ -1,30 +1,39 @@
1
- import { B as Buffer } from '../cells-CaXEx4lH.js';
2
- import { B as Backend, K as Key } from '../backend-BcB7VR87.js';
3
-
4
- declare class TestBackend implements Backend {
5
- private readonly cols;
6
- private readonly rows;
7
- frames: string[];
8
- private buffers;
9
- private readonly subscribers;
10
- constructor(cols?: number, rows?: number);
11
- size(): {
12
- width: number;
13
- height: number;
14
- };
15
- draw(buffer: Buffer): void;
16
- get lastFrame(): string;
17
- get lastBuffer(): Buffer | null;
18
- onKey(handler: (key: Key) => void): () => void;
19
- /** Synchronously deliver one Key to every subscriber. */
20
- press(key: Partial<Key> & {
21
- name: string;
22
- }): void;
23
- /** Emit one Key per character; printable chars only. */
24
- type(text: string): void;
25
- dispose(): void;
1
+ import { t as Buffer } from "../cells-C-GthybI.js";
2
+ import { n as Key, t as Backend } from "../backend-CT8SGLO5.js";
3
+ //#region src/testing/test-backend.d.ts
4
+ export declare class TestBackend implements Backend {
5
+ private readonly cols;
6
+ private readonly rows;
7
+ frames: string[];
8
+ private buffers;
9
+ private readonly subscribers;
10
+ constructor(cols?: number, rows?: number);
11
+ size(): {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ draw(buffer: Buffer): void;
16
+ get lastFrame(): string;
17
+ get lastBuffer(): Buffer | null;
18
+ onKey(handler: (key: Key) => void): () => void;
19
+ /**
20
+ * Synchronously deliver one Key to every subscriber. Throws on a name no
21
+ * terminal can produce (`'space'`, `'enter'`): a test that presses such a key
22
+ * exercises a branch real input never reaches, and would pass anyway.
23
+ */
24
+ press(key: Partial<Key> & {
25
+ name: string;
26
+ }): void;
27
+ /** Emit one Key per character; printable chars only. */
28
+ type(text: string): void;
29
+ /** Deliver `text` as ONE 'paste' key — what a TTY backend emits for a bracketed paste. */
30
+ paste(text: string): void;
31
+ /** Deliver one mouse-wheel step at cell (x, y) — what a TTY backend with `mouse` on emits. */
32
+ wheel(direction: "up" | "down", x?: number, y?: number): void;
33
+ dispose(): void;
26
34
  }
27
-
35
+ //#endregion
36
+ //#region src/testing/index.d.ts
28
37
  /**
29
38
  * Resolve after pending microtasks have drained. Use after `backend.press(...)`
30
39
  * to wait for React's state update + the scheduled repaint:
@@ -33,7 +42,7 @@ declare class TestBackend implements Backend {
33
42
  * await flush();
34
43
  * expect(backend.lastFrame).toBe('a');
35
44
  */
36
- declare function flush(): Promise<void>;
45
+ export declare function flush(): Promise<void>;
37
46
  /**
38
47
  * Wait for React scheduler-driven re-renders triggered by `setState` inside
39
48
  * `useEffect` (e.g. a Form field registering, then the group auto-focusing the
@@ -56,8 +65,7 @@ declare function flush(): Promise<void>;
56
65
  * Called with no backend it falls back to a single macrotask round (the legacy
57
66
  * behavior) for callers that don't need cascade-settling.
58
67
  */
59
- declare function flushAsync(backend?: {
60
- readonly frames: readonly unknown[];
68
+ export declare function flushAsync(backend?: {
69
+ readonly frames: readonly unknown[];
61
70
  }): Promise<void>;
62
-
63
- export { TestBackend, flush, flushAsync };
71
+ //#endregion
@@ -1,72 +1,147 @@
1
- // src/testing/test-backend.ts
1
+ import { t as NAMED_KEYS } from "../keys-Ck-RALk_.js";
2
+ //#region src/testing/test-backend.ts
3
+ const KEY_NAME_HINTS = {
4
+ space: " ",
5
+ enter: "return",
6
+ esc: "escape",
7
+ del: "delete",
8
+ ins: "insert",
9
+ pgup: "pageup",
10
+ pgdn: "pagedown",
11
+ pgdown: "pagedown",
12
+ bs: "backspace"
13
+ };
14
+ function assertRealKeyName(name) {
15
+ if ([...name].length === 1 || name.startsWith("csi-")) return;
16
+ if (NAMED_KEYS.includes(name)) return;
17
+ const hint = KEY_NAME_HINTS[name.toLowerCase()];
18
+ throw new Error(`TestBackend.press: '${name}' is not a key name any terminal produces` + (hint !== void 0 ? ` — use '${hint}'.` : `. A printable key is named by its character (':' not 'colon'); named keys: ${NAMED_KEYS.join(", ")}.`));
19
+ }
2
20
  var TestBackend = class {
3
- constructor(cols = 40, rows = 10) {
4
- this.cols = cols;
5
- this.rows = rows;
6
- }
7
- cols;
8
- rows;
9
- frames = [];
10
- buffers = [];
11
- subscribers = /* @__PURE__ */ new Set();
12
- size() {
13
- return { width: this.cols, height: this.rows };
14
- }
15
- draw(buffer) {
16
- this.frames.push(buffer.toString());
17
- this.buffers.push(buffer);
18
- }
19
- get lastFrame() {
20
- return this.frames[this.frames.length - 1] ?? "";
21
- }
22
- get lastBuffer() {
23
- return this.buffers[this.buffers.length - 1] ?? null;
24
- }
25
- onKey(handler) {
26
- this.subscribers.add(handler);
27
- return () => {
28
- this.subscribers.delete(handler);
29
- };
30
- }
31
- /** Synchronously deliver one Key to every subscriber. */
32
- press(key) {
33
- const k = {
34
- sequence: key.sequence ?? "",
35
- ctrl: key.ctrl ?? false,
36
- meta: key.meta ?? false,
37
- shift: key.shift ?? false,
38
- name: key.name
39
- };
40
- for (const h of [...this.subscribers]) h(k);
41
- }
42
- /** Emit one Key per character; printable chars only. */
43
- type(text) {
44
- for (const ch of text) this.press({ name: ch, sequence: ch });
45
- }
46
- // eslint-disable-next-line @typescript-eslint/no-empty-function
47
- dispose() {
48
- }
21
+ cols;
22
+ rows;
23
+ frames = [];
24
+ buffers = [];
25
+ subscribers = /* @__PURE__ */ new Set();
26
+ constructor(cols = 40, rows = 10) {
27
+ this.cols = cols;
28
+ this.rows = rows;
29
+ }
30
+ size() {
31
+ return {
32
+ width: this.cols,
33
+ height: this.rows
34
+ };
35
+ }
36
+ draw(buffer) {
37
+ this.frames.push(buffer.toString());
38
+ this.buffers.push(buffer);
39
+ }
40
+ get lastFrame() {
41
+ return this.frames[this.frames.length - 1] ?? "";
42
+ }
43
+ get lastBuffer() {
44
+ return this.buffers[this.buffers.length - 1] ?? null;
45
+ }
46
+ onKey(handler) {
47
+ this.subscribers.add(handler);
48
+ return () => {
49
+ this.subscribers.delete(handler);
50
+ };
51
+ }
52
+ /**
53
+ * Synchronously deliver one Key to every subscriber. Throws on a name no
54
+ * terminal can produce (`'space'`, `'enter'`): a test that presses such a key
55
+ * exercises a branch real input never reaches, and would pass anyway.
56
+ */
57
+ press(key) {
58
+ assertRealKeyName(key.name);
59
+ const k = {
60
+ ...key.text !== void 0 ? { text: key.text } : {},
61
+ ...key.x !== void 0 ? { x: key.x } : {},
62
+ ...key.y !== void 0 ? { y: key.y } : {},
63
+ sequence: key.sequence ?? "",
64
+ ctrl: key.ctrl ?? false,
65
+ meta: key.meta ?? false,
66
+ shift: key.shift ?? false,
67
+ name: key.name
68
+ };
69
+ for (const h of [...this.subscribers]) h(k);
70
+ }
71
+ /** Emit one Key per character; printable chars only. */
72
+ type(text) {
73
+ for (const ch of text) this.press({
74
+ name: ch,
75
+ sequence: ch
76
+ });
77
+ }
78
+ /** Deliver `text` as ONE 'paste' key — what a TTY backend emits for a bracketed paste. */
79
+ paste(text) {
80
+ this.press({
81
+ name: "paste",
82
+ text: text.replace(/\r\n?/g, "\n")
83
+ });
84
+ }
85
+ /** Deliver one mouse-wheel step at cell (x, y) — what a TTY backend with `mouse` on emits. */
86
+ wheel(direction, x = 0, y = 0) {
87
+ this.press({
88
+ name: direction === "up" ? "wheelup" : "wheeldown",
89
+ x,
90
+ y
91
+ });
92
+ }
93
+ dispose() {}
49
94
  };
50
-
51
- // src/testing/index.ts
95
+ //#endregion
96
+ //#region src/testing/index.ts
97
+ /**
98
+ * Resolve after pending microtasks have drained. Use after `backend.press(...)`
99
+ * to wait for React's state update + the scheduled repaint:
100
+ *
101
+ * backend.press({ name: 'a' });
102
+ * await flush();
103
+ * expect(backend.lastFrame).toBe('a');
104
+ */
52
105
  async function flush() {
53
- await Promise.resolve();
54
- await Promise.resolve();
106
+ await Promise.resolve();
107
+ await Promise.resolve();
55
108
  }
109
+ /**
110
+ * Wait for React scheduler-driven re-renders triggered by `setState` inside
111
+ * `useEffect` (e.g. a Form field registering, then the group auto-focusing the
112
+ * first field) to fully commit.
113
+ *
114
+ * A passive-effect `setState` lands on React's default lane, which the
115
+ * production Scheduler only drains on a macrotask — there is no synchronous
116
+ * escape hatch in react-reconciler. A single `setTimeout(0)` (the old impl)
117
+ * therefore advances only ONE step of a multi-step effect cascade and can also
118
+ * lose the race against the Scheduler's own MessageChannel macrotask; both
119
+ * surface as a stale/empty `lastFrame`.
120
+ *
121
+ * Passing the `TestBackend` makes this deterministic: each round yields one
122
+ * macrotask (draining the Scheduler, which flushes that commit's passive
123
+ * effects exactly as in production) plus a microtask pair (for the coalesced
124
+ * repaint), and we stop once two consecutive rounds add no new frame. Two
125
+ * rounds — not one — so a single macrotask-ordering inversion (Scheduler work
126
+ * landing just after our `setTimeout`) can't read as premature quiescence.
127
+ *
128
+ * Called with no backend it falls back to a single macrotask round (the legacy
129
+ * behavior) for callers that don't need cascade-settling.
130
+ */
56
131
  async function flushAsync(backend) {
57
- if (!backend) {
58
- await new Promise((resolve) => setTimeout(resolve, 0));
59
- return;
60
- }
61
- const MAX_ROUNDS = 20;
62
- let stableRounds = 0;
63
- for (let i = 0; i < MAX_ROUNDS && stableRounds < 2; i++) {
64
- const before = backend.frames.length;
65
- await new Promise((resolve) => setTimeout(resolve, 0));
66
- await Promise.resolve();
67
- await Promise.resolve();
68
- stableRounds = backend.frames.length === before ? stableRounds + 1 : 0;
69
- }
132
+ if (!backend) {
133
+ await new Promise((resolve) => setTimeout(resolve, 0));
134
+ return;
135
+ }
136
+ const MAX_ROUNDS = 20;
137
+ let stableRounds = 0;
138
+ for (let i = 0; i < MAX_ROUNDS && stableRounds < 2; i++) {
139
+ const before = backend.frames.length;
140
+ await new Promise((resolve) => setTimeout(resolve, 0));
141
+ await Promise.resolve();
142
+ await Promise.resolve();
143
+ stableRounds = backend.frames.length === before ? stableRounds + 1 : 0;
144
+ }
70
145
  }
71
-
146
+ //#endregion
72
147
  export { TestBackend, flush, flushAsync };
@@ -0,0 +1,180 @@
1
+ //#region src/cells.ts
2
+ var Buffer = class {
3
+ width;
4
+ height;
5
+ cells;
6
+ constructor(width, height) {
7
+ this.width = Math.max(0, width);
8
+ this.height = Math.max(0, height);
9
+ this.cells = Array.from({ length: this.width * this.height }, () => ({
10
+ char: " ",
11
+ style: {}
12
+ }));
13
+ }
14
+ set(x, y, char, style = {}) {
15
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
16
+ this.cells[y * this.width + x] = {
17
+ char,
18
+ style
19
+ };
20
+ }
21
+ get(x, y) {
22
+ return this.cells[y * this.width + x] ?? {
23
+ char: " ",
24
+ style: {}
25
+ };
26
+ }
27
+ toString() {
28
+ const lines = [];
29
+ for (let y = 0; y < this.height; y++) {
30
+ let line = "";
31
+ for (let x = 0; x < this.width; x++) line += this.get(x, y).char;
32
+ lines.push(line.replace(/ +$/u, ""));
33
+ }
34
+ return lines.join("\n").replace(/\n+$/u, "");
35
+ }
36
+ };
37
+ //#endregion
38
+ //#region src/host/borders.ts
39
+ /** The library-wide default for components that draw a box-border chrome
40
+ * (DialogHost wrappers, Menu panels, Table grids). Declared once so the
41
+ * default is a single edit, not one per component. */
42
+ const DEFAULT_BORDER_STYLE = "round";
43
+ const GRID_CHARS = {
44
+ single: {
45
+ h: "─",
46
+ v: "│",
47
+ tl: "┌",
48
+ tr: "┐",
49
+ bl: "└",
50
+ br: "┘",
51
+ tDown: "┬",
52
+ tUp: "┴",
53
+ tRight: "├",
54
+ tLeft: "┤",
55
+ cross: "┼"
56
+ },
57
+ round: {
58
+ h: "─",
59
+ v: "│",
60
+ tl: "╭",
61
+ tr: "╮",
62
+ bl: "╰",
63
+ br: "╯",
64
+ tDown: "┬",
65
+ tUp: "┴",
66
+ tRight: "├",
67
+ tLeft: "┤",
68
+ cross: "┼"
69
+ },
70
+ double: {
71
+ h: "═",
72
+ v: "║",
73
+ tl: "╔",
74
+ tr: "╗",
75
+ bl: "╚",
76
+ br: "╝",
77
+ tDown: "╦",
78
+ tUp: "╩",
79
+ tRight: "╠",
80
+ tLeft: "╣",
81
+ cross: "╬"
82
+ },
83
+ bold: {
84
+ h: "━",
85
+ v: "┃",
86
+ tl: "┏",
87
+ tr: "┓",
88
+ bl: "┗",
89
+ br: "┛",
90
+ tDown: "┳",
91
+ tUp: "┻",
92
+ tRight: "┣",
93
+ tLeft: "┫",
94
+ cross: "╋"
95
+ },
96
+ classic: {
97
+ h: "-",
98
+ v: "|",
99
+ tl: "+",
100
+ tr: "+",
101
+ bl: "+",
102
+ br: "+",
103
+ tDown: "+",
104
+ tUp: "+",
105
+ tRight: "+",
106
+ tLeft: "+",
107
+ cross: "+"
108
+ }
109
+ };
110
+ const BORDER_CHARS = GRID_CHARS;
111
+ //#endregion
112
+ //#region src/wrap.ts
113
+ const ELLIPSIS = "…";
114
+ /**
115
+ * Lay out `text` into display lines fitting within `width` cells.
116
+ * Assumes 1 code point = 1 cell (no CJK/emoji width awareness in M1d).
117
+ *
118
+ * - 'wrap' — word-wrap at spaces; any single word longer than width is char-wrapped.
119
+ * - 'truncate' — each source line truncated to width, with `…` in the last cell when truncated.
120
+ * - 'none' — each source line preserved unchanged (caller is responsible for overflow).
121
+ *
122
+ * Always returns at least one line (empty input → `['']`, matching measureText's height=1 default).
123
+ */
124
+ function wrapText(text, width, mode) {
125
+ if (width < 0) width = 0;
126
+ const out = [];
127
+ for (const source of text.split("\n")) {
128
+ if (mode === "none") {
129
+ out.push(source);
130
+ continue;
131
+ }
132
+ if (mode === "truncate") {
133
+ out.push(truncateLine(source, width));
134
+ continue;
135
+ }
136
+ wrapLine(source, width, out);
137
+ }
138
+ if (out.length === 0) out.push("");
139
+ return out;
140
+ }
141
+ function truncateLine(line, width) {
142
+ if (width <= 0) return "";
143
+ const chars = [...line];
144
+ if (chars.length <= width) return line;
145
+ if (width === 1) return ELLIPSIS;
146
+ return chars.slice(0, width - 1).join("") + ELLIPSIS;
147
+ }
148
+ function wrapLine(line, width, out) {
149
+ if (width === 0) {
150
+ out.push("");
151
+ return;
152
+ }
153
+ if (line === "") {
154
+ out.push("");
155
+ return;
156
+ }
157
+ let current = "";
158
+ for (const word of line.split(" ")) {
159
+ const candidate = current ? current + " " + word : word;
160
+ if ([...candidate].length <= width) {
161
+ current = candidate;
162
+ continue;
163
+ }
164
+ if (current) {
165
+ out.push(current);
166
+ current = "";
167
+ }
168
+ if ([...word].length > width) {
169
+ let remainder = [...word];
170
+ while (remainder.length > width) {
171
+ out.push(remainder.slice(0, width).join(""));
172
+ remainder = remainder.slice(width);
173
+ }
174
+ current = remainder.join("");
175
+ } else current = word;
176
+ }
177
+ if (current) out.push(current);
178
+ }
179
+ //#endregion
180
+ export { Buffer as a, GRID_CHARS as i, BORDER_CHARS as n, DEFAULT_BORDER_STYLE as r, wrapText as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowtty/core",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,13 +41,13 @@
41
41
  "dist"
42
42
  ],
43
43
  "scripts": {
44
- "build": "tsup",
44
+ "build": "tsdown",
45
45
  "prepublishOnly": "npm run build"
46
46
  },
47
47
  "dependencies": {
48
48
  "yoga-layout": "^3.2.1"
49
49
  },
50
50
  "devDependencies": {
51
- "tsup": "^8"
51
+ "tsdown": "^0.23.0"
52
52
  }
53
53
  }
@@ -1,74 +0,0 @@
1
- import { B as Buffer } from './cells-CaXEx4lH.js';
2
-
3
- interface Key {
4
- /**
5
- * Canonical name of the key. For printable ASCII characters this is the
6
- * character itself ('a', '!', ' '). For named keys: 'return', 'escape',
7
- * 'tab', 'backspace', 'delete', 'up', 'down', 'left', 'right', 'home',
8
- * 'end', 'pageup', 'pagedown'.
9
- */
10
- name: string;
11
- /** Raw byte sequence as received from the source (empty for synthetic keys). */
12
- sequence: string;
13
- ctrl: boolean;
14
- meta: boolean;
15
- shift: boolean;
16
- }
17
-
18
- interface Backend {
19
- size(): {
20
- width: number;
21
- height: number;
22
- };
23
- draw(buffer: Buffer): void;
24
- /**
25
- * Subscribe to raw key events. Returns an unsubscribe function.
26
- * Backends without an input source omit this method.
27
- */
28
- onKey?(handler: (key: Key) => void): () => void;
29
- /**
30
- * Subscribe to terminal-resize events. Returns an unsubscribe function.
31
- * Handlers are called AFTER `size()` reflects the new dimensions.
32
- * Backends with fixed dimensions (e.g. the test backend) omit this method.
33
- */
34
- onResize?(handler: () => void): () => void;
35
- dispose?(): void;
36
- /**
37
- * Append plain (already-styled, ANSI-ready) lines ABOVE the live region.
38
- * The lines scroll naturally into the terminal's scrollback. Backends
39
- * that own the whole screen (alt-screen TTY) or are headless (TestBackend)
40
- * may omit this; components like <Static> check for presence at runtime
41
- * and degrade gracefully when absent.
42
- */
43
- printStatic?(lines: string[]): void;
44
- /**
45
- * Whether this backend owns the entire render area — i.e. components can
46
- * use the full `size()` for layout and overlay larger panels (Menu cascade,
47
- * full-screen DialogHost) on top.
48
- *
49
- * true — TtyBackend (alt-screen), TestBackend (full buffer)
50
- * false — @flowtty/inline-tty-backend (only the live region is yours;
51
- * scrollback above is append-only and out of layout control)
52
- *
53
- * Defaults to `true` when omitted — preserves the behavior of existing
54
- * backends that don't declare the flag. Inline-style backends MUST set
55
- * it to `false` so capability-sensitive components (e.g. <Menu>) can
56
- * refuse to render rather than produce broken overflow UI.
57
- */
58
- fullScreen?: boolean;
59
- /**
60
- * Whether this backend can emit OSC 8 terminal hyperlinks (clickable links).
61
- *
62
- * true — TTY backends (alt-screen + inline) wrap linked cells in the
63
- * OSC 8 escape, so a `<Link>` is clickable in supporting terminals.
64
- * omitted — treated as false. The headless TestBackend and any output that
65
- * /false can't render clickable links leave them out, so `<Link>` should
66
- * degrade to styled text plus a visible URL.
67
- *
68
- * Feature-detected like `fullScreen`: components read it (via useBackend())
69
- * rather than assuming a capability.
70
- */
71
- hyperlinks?: boolean;
72
- }
73
-
74
- export type { Backend as B, Key as K };
@@ -1,31 +0,0 @@
1
- interface Style {
2
- fg?: string;
3
- bg?: string;
4
- bold?: boolean;
5
- dim?: boolean;
6
- underline?: boolean;
7
- inverse?: boolean;
8
- strikethrough?: boolean;
9
- /**
10
- * Target URL for an OSC 8 terminal hyperlink. Backends that can emit
11
- * clickable links (TTY / inline TTY) wrap this cell's char in the hyperlink
12
- * escape; backends that can't (headless test surface) ignore it. Carried in
13
- * Style so it threads through the same paint + diff path as visual attrs.
14
- */
15
- link?: string;
16
- }
17
- interface Cell {
18
- char: string;
19
- style: Style;
20
- }
21
- declare class Buffer {
22
- readonly width: number;
23
- readonly height: number;
24
- private readonly cells;
25
- constructor(width: number, height: number);
26
- set(x: number, y: number, char: string, style?: Style): void;
27
- get(x: number, y: number): Cell;
28
- toString(): string;
29
- }
30
-
31
- export { Buffer as B, type Cell as C, type Style as S };