@promptctl/rich-js 0.18.0 → 0.19.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/README.md +9 -7
- package/dist/core/anchor.d.ts +45 -0
- package/dist/core/anchor.js +39 -0
- package/dist/core/ansi.d.ts +65 -0
- package/dist/core/ansi.js +231 -0
- package/dist/core/box.d.ts +145 -0
- package/dist/core/box.js +372 -0
- package/dist/core/cells.d.ts +192 -0
- package/dist/core/cells.js +334 -0
- package/dist/core/clock.d.ts +48 -0
- package/dist/core/clock.js +42 -0
- package/dist/core/color.d.ts +326 -0
- package/dist/core/color.js +1225 -0
- package/dist/core/console.d.ts +196 -0
- package/dist/core/console.js +596 -0
- package/dist/core/easing.d.ts +86 -0
- package/dist/core/easing.js +171 -0
- package/dist/core/emoji.d.ts +24 -0
- package/dist/core/emoji.js +391 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +16 -0
- package/dist/core/export-html.d.ts +59 -0
- package/dist/core/export-html.js +197 -0
- package/dist/core/export-lines.d.ts +130 -0
- package/dist/core/export-lines.js +159 -0
- package/dist/core/export-svg.d.ts +49 -0
- package/dist/core/export-svg.js +213 -0
- package/dist/core/fnv1a.d.ts +10 -0
- package/dist/core/fnv1a.js +17 -0
- package/dist/core/highlighter.d.ts +33 -0
- package/dist/core/highlighter.js +190 -0
- package/dist/core/json.d.ts +27 -0
- package/dist/core/json.js +53 -0
- package/dist/core/markup.d.ts +140 -0
- package/dist/core/markup.js +654 -0
- package/dist/core/measure.d.ts +27 -0
- package/dist/core/measure.js +67 -0
- package/dist/core/memo.d.ts +19 -0
- package/dist/core/memo.js +34 -0
- package/dist/core/oklch.d.ts +161 -0
- package/dist/core/oklch.js +312 -0
- package/dist/core/osc8.d.ts +68 -0
- package/dist/core/osc8.js +78 -0
- package/dist/core/paint.d.ts +107 -0
- package/dist/core/paint.js +150 -0
- package/dist/core/place.d.ts +30 -0
- package/dist/core/place.js +49 -0
- package/dist/core/pretty.d.ts +247 -0
- package/dist/core/pretty.js +906 -0
- package/dist/core/protocol.d.ts +305 -0
- package/dist/core/protocol.js +183 -0
- package/dist/core/render.d.ts +87 -0
- package/dist/core/render.js +105 -0
- package/dist/core/segment.d.ts +162 -0
- package/dist/core/segment.js +488 -0
- package/dist/core/spinnerData.d.ts +12 -0
- package/dist/core/spinnerData.js +117 -0
- package/dist/core/strip.d.ts +152 -0
- package/dist/core/strip.js +326 -0
- package/dist/core/style.d.ts +189 -0
- package/dist/core/style.js +758 -0
- package/dist/core/subscription.d.ts +12 -0
- package/dist/core/subscription.js +12 -0
- package/dist/core/text.d.ts +283 -0
- package/dist/core/text.js +1097 -0
- package/dist/core/wrap.d.ts +46 -0
- package/dist/core/wrap.js +140 -0
- package/dist/host/app.d.ts +133 -0
- package/dist/host/app.js +285 -0
- package/dist/host/host-environment.d.ts +26 -0
- package/dist/host/host-environment.js +44 -0
- package/dist/host/index.d.ts +6 -0
- package/dist/host/index.js +3 -0
- package/dist/host/terminal-host.d.ts +180 -0
- package/dist/host/terminal-host.js +186 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +114 -0
- package/dist/node/prompt.d.ts +34 -0
- package/dist/node/prompt.js +51 -0
- package/dist/node/save.d.ts +18 -0
- package/dist/node/save.js +24 -0
- package/dist/node/terminal-host.d.ts +114 -0
- package/dist/node/terminal-host.js +227 -0
- package/dist/node/traceback.d.ts +38 -0
- package/dist/node/traceback.js +98 -0
- package/dist/renderables/align.d.ts +16 -0
- package/dist/renderables/align.js +26 -0
- package/dist/renderables/columns.d.ts +77 -0
- package/dist/renderables/columns.js +191 -0
- package/dist/renderables/constrain.d.ts +15 -0
- package/dist/renderables/constrain.js +31 -0
- package/dist/renderables/effect.d.ts +98 -0
- package/dist/renderables/effect.js +160 -0
- package/dist/renderables/embed.d.ts +107 -0
- package/dist/renderables/embed.js +160 -0
- package/dist/renderables/flexStrip.d.ts +41 -0
- package/dist/renderables/flexStrip.js +200 -0
- package/dist/renderables/group.d.ts +14 -0
- package/dist/renderables/group.js +22 -0
- package/dist/renderables/layout.d.ts +75 -0
- package/dist/renderables/layout.js +223 -0
- package/dist/renderables/live.d.ts +74 -0
- package/dist/renderables/live.js +188 -0
- package/dist/renderables/markdown.d.ts +38 -0
- package/dist/renderables/markdown.js +587 -0
- package/dist/renderables/padding.d.ts +59 -0
- package/dist/renderables/padding.js +147 -0
- package/dist/renderables/panel.d.ts +114 -0
- package/dist/renderables/panel.js +303 -0
- package/dist/renderables/progress.d.ts +178 -0
- package/dist/renderables/progress.js +362 -0
- package/dist/renderables/progressBar.d.ts +31 -0
- package/dist/renderables/progressBar.js +64 -0
- package/dist/renderables/prompt.d.ts +71 -0
- package/dist/renderables/prompt.js +165 -0
- package/dist/renderables/ratio.d.ts +123 -0
- package/dist/renderables/ratio.js +213 -0
- package/dist/renderables/rule.d.ts +26 -0
- package/dist/renderables/rule.js +86 -0
- package/dist/renderables/spinner.d.ts +57 -0
- package/dist/renderables/spinner.js +103 -0
- package/dist/renderables/status.d.ts +29 -0
- package/dist/renderables/status.js +46 -0
- package/dist/renderables/syntax.d.ts +65 -0
- package/dist/renderables/syntax.js +166 -0
- package/dist/renderables/table.d.ts +232 -0
- package/dist/renderables/table.js +973 -0
- package/dist/renderables/traceback.d.ts +32 -0
- package/dist/renderables/traceback.js +279 -0
- package/dist/renderables/tree.d.ts +76 -0
- package/dist/renderables/tree.js +177 -0
- package/dist/renderables/viewport.d.ts +114 -0
- package/dist/renderables/viewport.js +196 -0
- package/dist/template-bindings/color-funcs.d.ts +109 -0
- package/dist/template-bindings/color-funcs.js +243 -0
- package/dist/template-bindings/helpers.d.ts +26 -0
- package/dist/template-bindings/helpers.js +33 -0
- package/dist/template-bindings/index.d.ts +119 -0
- package/dist/template-bindings/index.js +162 -0
- package/dist/template-bindings/palette-funcs.d.ts +96 -0
- package/dist/template-bindings/palette-funcs.js +145 -0
- package/dist/template-bindings/style-funcs.d.ts +28 -0
- package/dist/template-bindings/style-funcs.js +157 -0
- package/dist/themes/buildPalette.d.ts +57 -0
- package/dist/themes/buildPalette.js +100 -0
- package/dist/themes/colorMath.d.ts +94 -0
- package/dist/themes/colorMath.js +280 -0
- package/dist/themes/colorRef.d.ts +36 -0
- package/dist/themes/colorRef.js +78 -0
- package/dist/themes/data/ansi-tables.d.ts +14 -0
- package/dist/themes/data/ansi-tables.js +49 -0
- package/dist/themes/data/atom-one-dark.d.ts +3 -0
- package/dist/themes/data/atom-one-dark.js +177 -0
- package/dist/themes/data/atom-one-light.d.ts +3 -0
- package/dist/themes/data/atom-one-light.js +177 -0
- package/dist/themes/data/catppuccin-frappe.d.ts +3 -0
- package/dist/themes/data/catppuccin-frappe.js +177 -0
- package/dist/themes/data/catppuccin-latte.d.ts +3 -0
- package/dist/themes/data/catppuccin-latte.js +177 -0
- package/dist/themes/data/catppuccin-macchiato.d.ts +3 -0
- package/dist/themes/data/catppuccin-macchiato.js +177 -0
- package/dist/themes/data/catppuccin-mocha.d.ts +3 -0
- package/dist/themes/data/catppuccin-mocha.js +177 -0
- package/dist/themes/data/cyberpunk.d.ts +17 -0
- package/dist/themes/data/cyberpunk.js +193 -0
- package/dist/themes/data/default.d.ts +3 -0
- package/dist/themes/data/default.js +160 -0
- package/dist/themes/data/dracula.d.ts +3 -0
- package/dist/themes/data/dracula.js +177 -0
- package/dist/themes/data/flexoki.d.ts +3 -0
- package/dist/themes/data/flexoki.js +177 -0
- package/dist/themes/data/gruvbox.d.ts +3 -0
- package/dist/themes/data/gruvbox.js +177 -0
- package/dist/themes/data/index.d.ts +35 -0
- package/dist/themes/data/index.js +64 -0
- package/dist/themes/data/monokai.d.ts +3 -0
- package/dist/themes/data/monokai.js +177 -0
- package/dist/themes/data/nord.d.ts +3 -0
- package/dist/themes/data/nord.js +177 -0
- package/dist/themes/data/rose-pine-dawn.d.ts +3 -0
- package/dist/themes/data/rose-pine-dawn.js +177 -0
- package/dist/themes/data/rose-pine-moon.d.ts +3 -0
- package/dist/themes/data/rose-pine-moon.js +177 -0
- package/dist/themes/data/rose-pine.d.ts +3 -0
- package/dist/themes/data/rose-pine.js +177 -0
- package/dist/themes/data/solarized-dark.d.ts +3 -0
- package/dist/themes/data/solarized-dark.js +177 -0
- package/dist/themes/data/solarized-light.d.ts +3 -0
- package/dist/themes/data/solarized-light.js +177 -0
- package/dist/themes/data/svg-export.d.ts +3 -0
- package/dist/themes/data/svg-export.js +177 -0
- package/dist/themes/data/textual-ansi.d.ts +3 -0
- package/dist/themes/data/textual-ansi.js +160 -0
- package/dist/themes/data/textual-dark.d.ts +3 -0
- package/dist/themes/data/textual-dark.js +160 -0
- package/dist/themes/data/textual-light.d.ts +3 -0
- package/dist/themes/data/textual-light.js +160 -0
- package/dist/themes/data/tokyo-night.d.ts +3 -0
- package/dist/themes/data/tokyo-night.js +177 -0
- package/dist/themes/data/types.d.ts +31 -0
- package/dist/themes/data/types.js +10 -0
- package/dist/themes/palette.d.ts +45 -0
- package/dist/themes/palette.js +71 -0
- package/dist/themes/ramp.d.ts +70 -0
- package/dist/themes/ramp.js +103 -0
- package/dist/themes/registry.d.ts +44 -0
- package/dist/themes/registry.js +105 -0
- package/dist/themes/terminalThemes.d.ts +49 -0
- package/dist/themes/terminalThemes.js +63 -0
- package/dist/themes/transpose.d.ts +87 -0
- package/dist/themes/transpose.js +138 -0
- package/dist/widgets/button.d.ts +45 -0
- package/dist/widgets/button.js +177 -0
- package/dist/widgets/checkbox.d.ts +38 -0
- package/dist/widgets/checkbox.js +135 -0
- package/dist/widgets/dropdown.d.ts +64 -0
- package/dist/widgets/dropdown.js +416 -0
- package/dist/widgets/event-router.d.ts +71 -0
- package/dist/widgets/event-router.js +629 -0
- package/dist/widgets/focus-manager.d.ts +45 -0
- package/dist/widgets/focus-manager.js +191 -0
- package/dist/widgets/hit.d.ts +64 -0
- package/dist/widgets/hit.js +113 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +14 -0
- package/dist/widgets/ink.d.ts +22 -0
- package/dist/widgets/ink.js +31 -0
- package/dist/widgets/slider.d.ts +54 -0
- package/dist/widgets/slider.js +247 -0
- package/dist/widgets/static-item.d.ts +34 -0
- package/dist/widgets/static-item.js +54 -0
- package/dist/widgets/text-input.d.ts +283 -0
- package/dist/widgets/text-input.js +1174 -0
- package/dist/widgets/themed-widget.d.ts +20 -0
- package/dist/widgets/themed-widget.js +89 -0
- package/dist/widgets/toggle.d.ts +41 -0
- package/dist/widgets/toggle.js +157 -0
- package/dist/widgets/types.d.ts +87 -0
- package/dist/widgets/types.js +30 -0
- package/dist/widgets/widget-app.d.ts +65 -0
- package/dist/widgets/widget-app.js +206 -0
- package/dist/widgets/widget-base.d.ts +42 -0
- package/dist/widgets/widget-base.js +172 -0
- package/package.json +16 -2
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventRouter — parses raw stdin into KeyEvent / ScreenMouseEvent values
|
|
3
|
+
* and dispatches them to widgets via a FocusManager and the frame on screen.
|
|
4
|
+
*
|
|
5
|
+
* It reads the terminal and never sets it up: raw mode and mouse tracking are
|
|
6
|
+
* the runtime's (`App`), which undoes them on every path out.
|
|
7
|
+
*
|
|
8
|
+
* [LAW:single-enforcer] ANSI escape parsing lives only here — widgets never
|
|
9
|
+
* see raw bytes. One parser, one dispatch surface.
|
|
10
|
+
*
|
|
11
|
+
* [LAW:dataflow-not-control-flow] The same parse pipeline runs for every
|
|
12
|
+
* input chunk: append → consume one event at a time until the buffer can't
|
|
13
|
+
* yield more. The byte's value, not a side-mode, decides which event is
|
|
14
|
+
* emitted. There is no "are we mid-sequence" mode flag — the buffer head
|
|
15
|
+
* carries all the state we need.
|
|
16
|
+
*
|
|
17
|
+
* Lone-ESC handling: a bare `\x1b` is ambiguous (escape key vs. start of a
|
|
18
|
+
* CSI sequence). When the buffer is drained but ends with a lone ESC, the
|
|
19
|
+
* router defers emission via setTimeout(0). The next chunk cancels the timer
|
|
20
|
+
* if it extends the sequence. Tests can call `flush()` to drain synchronously.
|
|
21
|
+
*/
|
|
22
|
+
import { KeyEvent } from "./types.js";
|
|
23
|
+
import { drew, originOf, scrollTargetAt, widgetAt } from "./hit.js";
|
|
24
|
+
// Single-byte → key name table for the trivial cases.
|
|
25
|
+
const SINGLE_BYTE_KEYS = {
|
|
26
|
+
0x09: "tab",
|
|
27
|
+
0x0a: "enter",
|
|
28
|
+
0x0d: "enter",
|
|
29
|
+
0x1b: "escape",
|
|
30
|
+
0x08: "backspace",
|
|
31
|
+
0x7f: "backspace",
|
|
32
|
+
0x20: "space",
|
|
33
|
+
};
|
|
34
|
+
// Final-byte → key name for the simple `ESC[<X>` arrow / nav forms.
|
|
35
|
+
const CSI_LETTER_KEYS = {
|
|
36
|
+
A: "up",
|
|
37
|
+
B: "down",
|
|
38
|
+
C: "right",
|
|
39
|
+
D: "left",
|
|
40
|
+
H: "home",
|
|
41
|
+
F: "end",
|
|
42
|
+
Z: "tab", // shift+tab encodes as ESC[Z
|
|
43
|
+
};
|
|
44
|
+
// `ESC[<n>~` numeric forms for navigation / function keys.
|
|
45
|
+
const CSI_TILDE_KEYS = {
|
|
46
|
+
"1": "home",
|
|
47
|
+
"2": "insert",
|
|
48
|
+
"3": "delete",
|
|
49
|
+
"4": "end",
|
|
50
|
+
"5": "pageup",
|
|
51
|
+
"6": "pagedown",
|
|
52
|
+
"7": "home",
|
|
53
|
+
"8": "end",
|
|
54
|
+
"11": "f1",
|
|
55
|
+
"12": "f2",
|
|
56
|
+
"13": "f3",
|
|
57
|
+
"14": "f4",
|
|
58
|
+
"15": "f5",
|
|
59
|
+
"17": "f6",
|
|
60
|
+
"18": "f7",
|
|
61
|
+
"19": "f8",
|
|
62
|
+
"20": "f9",
|
|
63
|
+
"21": "f10",
|
|
64
|
+
"23": "f11",
|
|
65
|
+
"24": "f12",
|
|
66
|
+
};
|
|
67
|
+
// SS3 form `ESC O X` for F1–F4 (and arrow keys on some terminals).
|
|
68
|
+
const SS3_KEYS = {
|
|
69
|
+
P: "f1",
|
|
70
|
+
Q: "f2",
|
|
71
|
+
R: "f3",
|
|
72
|
+
S: "f4",
|
|
73
|
+
A: "up",
|
|
74
|
+
B: "down",
|
|
75
|
+
C: "right",
|
|
76
|
+
D: "left",
|
|
77
|
+
H: "home",
|
|
78
|
+
F: "end",
|
|
79
|
+
};
|
|
80
|
+
const ESC = 0x1b;
|
|
81
|
+
const LBRACKET = 0x5b; // [
|
|
82
|
+
const LANGLE = 0x3c; // <
|
|
83
|
+
const O_BYTE = 0x4f; // O
|
|
84
|
+
const M_UPPER = 0x4d; // M
|
|
85
|
+
const M_LOWER = 0x6d; // m
|
|
86
|
+
// [LAW:single-enforcer] Shared encode/decode singletons. The parser
|
|
87
|
+
// receives `Uint8Array | string` from the host and walks bytes through
|
|
88
|
+
// its state machine; conversion happens at the two boundaries (string
|
|
89
|
+
// input → bytes via TextEncoder; printable-byte run → JS string via
|
|
90
|
+
// TextDecoder) and only there. Module-level instances keep the seam
|
|
91
|
+
// allocation-free and runtime-agnostic — both APIs are universal across
|
|
92
|
+
// node and browser.
|
|
93
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
94
|
+
const UTF8_DECODER = new TextDecoder("utf-8");
|
|
95
|
+
const ASCII_DECODER = new TextDecoder("ascii");
|
|
96
|
+
const EMPTY_BYTES = new Uint8Array(0);
|
|
97
|
+
// Concatenate two byte arrays into a fresh Uint8Array. Replaces
|
|
98
|
+
// `Buffer.concat([...])` so the parser holds no node-specific globals.
|
|
99
|
+
function concatBytes(a, b) {
|
|
100
|
+
if (a.length === 0)
|
|
101
|
+
return b;
|
|
102
|
+
if (b.length === 0)
|
|
103
|
+
return a;
|
|
104
|
+
const merged = new Uint8Array(a.length + b.length);
|
|
105
|
+
merged.set(a, 0);
|
|
106
|
+
merged.set(b, a.length);
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
export class EventRouter {
|
|
110
|
+
source;
|
|
111
|
+
host;
|
|
112
|
+
// [LAW:types-are-the-program] The parse buffer is `Uint8Array`, not
|
|
113
|
+
// `Buffer` — node's Buffer extends Uint8Array, so Buffer chunks from
|
|
114
|
+
// process.stdin satisfy this type and the parser still works on node,
|
|
115
|
+
// while a browser host can emit native Uint8Array without the runtime
|
|
116
|
+
// depending on a Buffer global polyfill.
|
|
117
|
+
buffer = EMPTY_BYTES;
|
|
118
|
+
running = false;
|
|
119
|
+
dataUnsubscribe;
|
|
120
|
+
// [LAW:types-are-the-program] `setTimeout`/`clearTimeout` are universal
|
|
121
|
+
// across node and browser; `setImmediate` is node-only. Same one-macrotask
|
|
122
|
+
// semantics for our purpose (defer until current sync work + queued
|
|
123
|
+
// microtasks drain), without forcing a `setImmediate` polyfill on the
|
|
124
|
+
// browser host.
|
|
125
|
+
escTimer;
|
|
126
|
+
// [LAW:single-enforcer] Drag capture lives only here. A mouse_down's
|
|
127
|
+
// target is recorded; mouse_move/mouse_up between then and the next
|
|
128
|
+
// mouse_up route to it unconditionally and relative to where it is painted
|
|
129
|
+
// (`relocate`), so a drag past the widget's edge still drives its state
|
|
130
|
+
// (Slider's _dragging) and reports a column outside it. Cleared on the next
|
|
131
|
+
// mouse_up.
|
|
132
|
+
captured = null;
|
|
133
|
+
// Where the terminal last reported the pointer, and the widget under it on
|
|
134
|
+
// the frame on screen — the one widget `hovered` is true on.
|
|
135
|
+
pointer = null;
|
|
136
|
+
hoveredWidget = null;
|
|
137
|
+
// [LAW:dataflow-not-control-flow] Ordered chain; the dispatcher walks it
|
|
138
|
+
// in priority tiers and stops as soon as some participant calls
|
|
139
|
+
// `event.stop()`. Insertion order is preserved within each tier.
|
|
140
|
+
keyChain = [];
|
|
141
|
+
mouseHandlers = new Set();
|
|
142
|
+
constructor(options) {
|
|
143
|
+
this.source = options.source;
|
|
144
|
+
this.host = options.host;
|
|
145
|
+
// [LAW:single-enforcer] FocusManager owns Tab/Shift+Tab traversal —
|
|
146
|
+
// register it as a normal-priority handler so it participates in the
|
|
147
|
+
// chain. This MUST be the first registration so it lands ahead of any
|
|
148
|
+
// user-added normal handlers; widgets that want to suppress traversal
|
|
149
|
+
// call `event.stop()` from their own handleKey and FocusManager
|
|
150
|
+
// never runs for that event.
|
|
151
|
+
this.onKey((event) => this.source.focusManager.handleKey(event));
|
|
152
|
+
}
|
|
153
|
+
// --- Lifecycle ---
|
|
154
|
+
start() {
|
|
155
|
+
if (this.running)
|
|
156
|
+
return;
|
|
157
|
+
this.running = true;
|
|
158
|
+
// Subscribing starts the underlying stream flowing on node, and is all
|
|
159
|
+
// it takes on a transport with no paused mode.
|
|
160
|
+
this.dataUnsubscribe = this.host.onData((chunk) => this.feed(chunk));
|
|
161
|
+
}
|
|
162
|
+
stop() {
|
|
163
|
+
if (this.running) {
|
|
164
|
+
this.running = false;
|
|
165
|
+
if (this.dataUnsubscribe) {
|
|
166
|
+
this.dataUnsubscribe();
|
|
167
|
+
this.dataUnsubscribe = undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// [LAW:one-source-of-truth] Per-session state belongs to one session.
|
|
171
|
+
// stop() leaves the router in the same shape a fresh construction
|
|
172
|
+
// would: empty parse buffer, no captured widget, nothing hovered, no
|
|
173
|
+
// pending ESC timer.
|
|
174
|
+
// All cleanup is outside the `running` guard because feed() can arm
|
|
175
|
+
// escTimer (and grow buffer) even when running is false — without
|
|
176
|
+
// those, a pre-start feed could leave a deferred escape firing into
|
|
177
|
+
// the next session. Clearing already-empty state is a no-op so this
|
|
178
|
+
// stays safe on repeated stop().
|
|
179
|
+
if (this.escTimer) {
|
|
180
|
+
clearTimeout(this.escTimer);
|
|
181
|
+
this.escTimer = undefined;
|
|
182
|
+
}
|
|
183
|
+
this.buffer = EMPTY_BYTES;
|
|
184
|
+
this.captured = null;
|
|
185
|
+
this.pointer = null;
|
|
186
|
+
this.hover(null);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read hover again off the frame just painted: under a pointer that has not
|
|
190
|
+
* moved, a widget may have gone, moved, or arrived.
|
|
191
|
+
*
|
|
192
|
+
* [LAW:one-source-of-truth] Hover is the widget under the pointer on the
|
|
193
|
+
* frame on screen, so it is re-read whenever either one changes.
|
|
194
|
+
*/
|
|
195
|
+
framePainted() {
|
|
196
|
+
const hit = this.pointer && widgetAt(this.source.frame, this.pointer.x, this.pointer.y);
|
|
197
|
+
this.hover(hit ? hit.widget : null);
|
|
198
|
+
}
|
|
199
|
+
// --- External hooks ---
|
|
200
|
+
onKey(handler, options) {
|
|
201
|
+
const entry = {
|
|
202
|
+
handler,
|
|
203
|
+
priority: options?.priority ?? "normal",
|
|
204
|
+
};
|
|
205
|
+
this.keyChain.push(entry);
|
|
206
|
+
return () => {
|
|
207
|
+
const idx = this.keyChain.indexOf(entry);
|
|
208
|
+
if (idx !== -1)
|
|
209
|
+
this.keyChain.splice(idx, 1);
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
onMouse(handler) {
|
|
213
|
+
this.mouseHandlers.add(handler);
|
|
214
|
+
return () => this.mouseHandlers.delete(handler);
|
|
215
|
+
}
|
|
216
|
+
// --- Test / advanced API ---
|
|
217
|
+
/** Feed a chunk of bytes (or a string of bytes) into the parser. */
|
|
218
|
+
feed(chunk) {
|
|
219
|
+
const next = typeof chunk === "string" ? UTF8_ENCODER.encode(chunk) : chunk;
|
|
220
|
+
this.buffer = concatBytes(this.buffer, next);
|
|
221
|
+
if (this.escTimer) {
|
|
222
|
+
clearTimeout(this.escTimer);
|
|
223
|
+
this.escTimer = undefined;
|
|
224
|
+
}
|
|
225
|
+
this.drain();
|
|
226
|
+
// [LAW:dataflow-not-control-flow] If a lone ESC remains, defer emission
|
|
227
|
+
// until the next macrotask so a follow-up chunk can extend it. The
|
|
228
|
+
// explicit `0` is structural deferral, not a throttle — it just lets
|
|
229
|
+
// the current sync work + queued microtasks drain before flushing.
|
|
230
|
+
if (this.buffer.length === 1 && this.buffer[0] === ESC) {
|
|
231
|
+
this.escTimer = setTimeout(() => {
|
|
232
|
+
this.escTimer = undefined;
|
|
233
|
+
this.flush();
|
|
234
|
+
}, 0);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Force any pending lone ESC out of the buffer. */
|
|
238
|
+
flush() {
|
|
239
|
+
if (this.escTimer) {
|
|
240
|
+
clearTimeout(this.escTimer);
|
|
241
|
+
this.escTimer = undefined;
|
|
242
|
+
}
|
|
243
|
+
if (this.buffer.length > 0 && this.buffer[0] === ESC && this.buffer.length === 1) {
|
|
244
|
+
this.buffer = this.buffer.subarray(1);
|
|
245
|
+
this.dispatchKey(new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// --- Internals ---
|
|
249
|
+
drain() {
|
|
250
|
+
while (this.buffer.length > 0) {
|
|
251
|
+
const result = this.consumeOne(this.buffer);
|
|
252
|
+
if (result.kind === "incomplete")
|
|
253
|
+
return;
|
|
254
|
+
this.buffer = this.buffer.subarray(result.bytes);
|
|
255
|
+
if (result.kind === "key")
|
|
256
|
+
this.dispatchKey(result.event);
|
|
257
|
+
else if (result.kind === "mouse")
|
|
258
|
+
this.dispatchMouse(result.event);
|
|
259
|
+
// skip → drop bytes silently
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
consumeOne(buf) {
|
|
263
|
+
const b0 = buf[0];
|
|
264
|
+
if (b0 === ESC)
|
|
265
|
+
return this.consumeEscape(buf);
|
|
266
|
+
// Ctrl+C
|
|
267
|
+
if (b0 === 0x03) {
|
|
268
|
+
return {
|
|
269
|
+
kind: "key",
|
|
270
|
+
bytes: 1,
|
|
271
|
+
event: new KeyEvent({ key: "c", character: "", shift: false, ctrl: true, meta: false }),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// Named single-byte keys (tab/enter/space/backspace).
|
|
275
|
+
const named = SINGLE_BYTE_KEYS[b0];
|
|
276
|
+
if (named && named !== "escape") {
|
|
277
|
+
return {
|
|
278
|
+
kind: "key",
|
|
279
|
+
bytes: 1,
|
|
280
|
+
event: new KeyEvent({
|
|
281
|
+
key: named,
|
|
282
|
+
character: named === "space" ? " " : "",
|
|
283
|
+
shift: false,
|
|
284
|
+
ctrl: false,
|
|
285
|
+
meta: false,
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Ctrl+letter (Ctrl+A=0x01 .. Ctrl+Z=0x1a), excluding the bytes already
|
|
290
|
+
// claimed by SINGLE_BYTE_KEYS above (tab=0x09, enter=0x0a/0x0d, backspace=0x08).
|
|
291
|
+
if (b0 < 0x20) {
|
|
292
|
+
const letter = String.fromCharCode(b0 + 0x60); // 0x01 → 'a'
|
|
293
|
+
return {
|
|
294
|
+
kind: "key",
|
|
295
|
+
bytes: 1,
|
|
296
|
+
event: new KeyEvent({ key: letter, character: "", shift: false, ctrl: true, meta: false }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// Printable ASCII / start of a UTF-8 multibyte character.
|
|
300
|
+
return this.consumePrintable(buf);
|
|
301
|
+
}
|
|
302
|
+
consumePrintable(buf) {
|
|
303
|
+
const b0 = buf[0];
|
|
304
|
+
let len = 1;
|
|
305
|
+
if (b0 >= 0xc0 && b0 < 0xe0)
|
|
306
|
+
len = 2;
|
|
307
|
+
else if (b0 >= 0xe0 && b0 < 0xf0)
|
|
308
|
+
len = 3;
|
|
309
|
+
else if (b0 >= 0xf0)
|
|
310
|
+
len = 4;
|
|
311
|
+
if (buf.length < len)
|
|
312
|
+
return { kind: "incomplete" };
|
|
313
|
+
const character = UTF8_DECODER.decode(buf.subarray(0, len));
|
|
314
|
+
return {
|
|
315
|
+
kind: "key",
|
|
316
|
+
bytes: len,
|
|
317
|
+
event: new KeyEvent({
|
|
318
|
+
key: character.toLowerCase(),
|
|
319
|
+
character,
|
|
320
|
+
shift: character.length === 1 && character !== character.toLowerCase(),
|
|
321
|
+
ctrl: false,
|
|
322
|
+
meta: false,
|
|
323
|
+
}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
consumeEscape(buf) {
|
|
327
|
+
if (buf.length === 1)
|
|
328
|
+
return { kind: "incomplete" };
|
|
329
|
+
const b1 = buf[1];
|
|
330
|
+
// ESC [ ... → CSI
|
|
331
|
+
if (b1 === LBRACKET)
|
|
332
|
+
return this.consumeCSI(buf);
|
|
333
|
+
// ESC O X → SS3 (F1–F4 / arrows on some terms)
|
|
334
|
+
if (b1 === O_BYTE)
|
|
335
|
+
return this.consumeSS3(buf);
|
|
336
|
+
// ESC ESC → escape key (some terminals double-send). Consume BOTH bytes
|
|
337
|
+
// so the user sees one escape event, not two. Without this, the second
|
|
338
|
+
// ESC remained in the buffer and produced a second escape via the
|
|
339
|
+
// lone-ESC flush path.
|
|
340
|
+
if (b1 === ESC) {
|
|
341
|
+
return {
|
|
342
|
+
kind: "key",
|
|
343
|
+
bytes: 2,
|
|
344
|
+
event: new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// ESC <named> / ESC <printable> → Alt-modified key.
|
|
348
|
+
// Terminals encode Alt+<key> as the literal byte preceded by ESC, so
|
|
349
|
+
// `ESC b` is Alt+B, `ESC <0x7f>` is Alt+Backspace, `ESC <0x20>` is
|
|
350
|
+
// Alt+Space, etc. The router surfaces this as `{ key, meta: true }` —
|
|
351
|
+
// matching the shape used for Ctrl-modified keys, so handlers can
|
|
352
|
+
// branch on `event.meta` without parsing escape sequences themselves.
|
|
353
|
+
//
|
|
354
|
+
// [LAW:one-source-of-truth] Named single-byte keys (space, tab,
|
|
355
|
+
// enter, backspace) use the SAME canonical key names on the Alt path
|
|
356
|
+
// as on the non-Alt path. Without this, `Alt+Space` decoded as
|
|
357
|
+
// `key: " "` while a bare space decoded as `key: "space"`, so a
|
|
358
|
+
// handler couldn't match Alt+Space via `event.key === "space" &&
|
|
359
|
+
// event.meta`. Routing through SINGLE_BYTE_KEYS first preserves the
|
|
360
|
+
// canonical naming.
|
|
361
|
+
//
|
|
362
|
+
// [LAW:one-source-of-truth] Modifier decoding for *all* navigation keys
|
|
363
|
+
// lives in this file: CSI param-style modifiers (e.g. `ESC[1;3D` for
|
|
364
|
+
// Alt+Left) flow through `decodeModifier`, and ESC-prefixed Alt forms
|
|
365
|
+
// flow through this branch. Widgets read `event.meta` and never re-parse.
|
|
366
|
+
const namedAlt = SINGLE_BYTE_KEYS[b1];
|
|
367
|
+
if (namedAlt && namedAlt !== "escape") {
|
|
368
|
+
return {
|
|
369
|
+
kind: "key",
|
|
370
|
+
bytes: 2,
|
|
371
|
+
event: new KeyEvent({
|
|
372
|
+
key: namedAlt,
|
|
373
|
+
character: "",
|
|
374
|
+
shift: false,
|
|
375
|
+
ctrl: false,
|
|
376
|
+
meta: true,
|
|
377
|
+
}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (b1 >= 0x20 && b1 <= 0x7e) {
|
|
381
|
+
const ch = String.fromCharCode(b1);
|
|
382
|
+
return {
|
|
383
|
+
kind: "key",
|
|
384
|
+
bytes: 2,
|
|
385
|
+
event: new KeyEvent({
|
|
386
|
+
key: ch.toLowerCase(),
|
|
387
|
+
character: "",
|
|
388
|
+
shift: ch !== ch.toLowerCase(),
|
|
389
|
+
ctrl: false,
|
|
390
|
+
meta: true,
|
|
391
|
+
}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Truly unrecognised follow-up byte → treat the ESC as a lone escape
|
|
395
|
+
// and re-process from b1 on the next pass.
|
|
396
|
+
return {
|
|
397
|
+
kind: "key",
|
|
398
|
+
bytes: 1,
|
|
399
|
+
event: new KeyEvent({ key: "escape", character: "", shift: false, ctrl: false, meta: false }),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
consumeSS3(buf) {
|
|
403
|
+
if (buf.length < 3)
|
|
404
|
+
return { kind: "incomplete" };
|
|
405
|
+
const final = String.fromCharCode(buf[2]);
|
|
406
|
+
const name = SS3_KEYS[final];
|
|
407
|
+
if (!name)
|
|
408
|
+
return { kind: "skip", bytes: 3 };
|
|
409
|
+
return {
|
|
410
|
+
kind: "key",
|
|
411
|
+
bytes: 3,
|
|
412
|
+
event: new KeyEvent({ key: name, character: "", shift: false, ctrl: false, meta: false }),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
consumeCSI(buf) {
|
|
416
|
+
// buf starts with ESC [
|
|
417
|
+
const next = buf[2];
|
|
418
|
+
if (next === undefined)
|
|
419
|
+
return { kind: "incomplete" };
|
|
420
|
+
// X10 mouse: ESC [ M b x y (3 raw bytes after M)
|
|
421
|
+
if (next === M_UPPER) {
|
|
422
|
+
if (buf.length < 6)
|
|
423
|
+
return { kind: "incomplete" };
|
|
424
|
+
const cb = buf[3] - 32;
|
|
425
|
+
const col = buf[4] - 32 - 1;
|
|
426
|
+
const row = buf[5] - 32 - 1;
|
|
427
|
+
return { kind: "mouse", bytes: 6, event: decodeMouseFromCb(cb, col, row, true) };
|
|
428
|
+
}
|
|
429
|
+
// SGR mouse: ESC [ < cb ; x ; y M|m
|
|
430
|
+
if (next === LANGLE) {
|
|
431
|
+
// Find terminator M or m.
|
|
432
|
+
for (let i = 3; i < buf.length; i++) {
|
|
433
|
+
const b = buf[i];
|
|
434
|
+
if (b === M_UPPER || b === M_LOWER) {
|
|
435
|
+
const params = ASCII_DECODER.decode(buf.subarray(3, i));
|
|
436
|
+
const parts = params.split(";");
|
|
437
|
+
if (parts.length !== 3)
|
|
438
|
+
return { kind: "skip", bytes: i + 1 };
|
|
439
|
+
const cb = parseInt(parts[0], 10);
|
|
440
|
+
const col = parseInt(parts[1], 10) - 1;
|
|
441
|
+
const row = parseInt(parts[2], 10) - 1;
|
|
442
|
+
if (Number.isNaN(cb) || Number.isNaN(col) || Number.isNaN(row)) {
|
|
443
|
+
return { kind: "skip", bytes: i + 1 };
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
kind: "mouse",
|
|
447
|
+
bytes: i + 1,
|
|
448
|
+
event: decodeMouseFromCb(cb, col, row, b === M_UPPER),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return { kind: "incomplete" };
|
|
453
|
+
}
|
|
454
|
+
// Generic CSI: collect parameter / intermediate bytes until a final byte
|
|
455
|
+
// in the range 0x40–0x7e is found.
|
|
456
|
+
let params = "";
|
|
457
|
+
for (let i = 2; i < buf.length; i++) {
|
|
458
|
+
const b = buf[i];
|
|
459
|
+
if (b >= 0x40 && b <= 0x7e) {
|
|
460
|
+
const final = String.fromCharCode(b);
|
|
461
|
+
return decodeCSI(params, final, i + 1);
|
|
462
|
+
}
|
|
463
|
+
params += String.fromCharCode(b);
|
|
464
|
+
}
|
|
465
|
+
return { kind: "incomplete" };
|
|
466
|
+
}
|
|
467
|
+
// --- Dispatch ---
|
|
468
|
+
// [LAW:dataflow-not-control-flow] Three-stage walk; same shape every
|
|
469
|
+
// dispatch, only the event's `stopped` flag short-circuits later stages.
|
|
470
|
+
// The router holds NO key-specific policy — Tab semantics live entirely
|
|
471
|
+
// in FocusManager (registered as a normal-priority handler at construction).
|
|
472
|
+
dispatchKey(event) {
|
|
473
|
+
// [LAW:one-source-of-truth] Snapshot the chain once at the start of
|
|
474
|
+
// dispatch. A handler that unsubscribes itself (or another handler)
|
|
475
|
+
// splices the live keyChain and would shift indices under the running
|
|
476
|
+
// for-of loop, silently skipping the next handler. The snapshot
|
|
477
|
+
// freezes the participant set for this dispatch; later registrations
|
|
478
|
+
// join the chain for the next event, removals take effect after.
|
|
479
|
+
const chain = [...this.keyChain];
|
|
480
|
+
// Stage 1: high-priority handlers (global overrides like Ctrl+C).
|
|
481
|
+
for (const entry of chain) {
|
|
482
|
+
if (event.stopped)
|
|
483
|
+
return;
|
|
484
|
+
if (entry.priority === "high")
|
|
485
|
+
entry.handler(event);
|
|
486
|
+
}
|
|
487
|
+
if (event.stopped)
|
|
488
|
+
return;
|
|
489
|
+
// Stage 2: the focused widget.
|
|
490
|
+
this.source.focusManager.current?.handleKey(event);
|
|
491
|
+
if (event.stopped)
|
|
492
|
+
return;
|
|
493
|
+
// Stage 3: normal-priority handlers (FocusManager's Tab traversal,
|
|
494
|
+
// plus any app-registered fallbacks).
|
|
495
|
+
for (const entry of chain) {
|
|
496
|
+
if (event.stopped)
|
|
497
|
+
return;
|
|
498
|
+
if (entry.priority === "normal")
|
|
499
|
+
entry.handler(event);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
dispatchMouse(event) {
|
|
503
|
+
for (const handler of this.mouseHandlers)
|
|
504
|
+
handler(event);
|
|
505
|
+
const frame = this.source.frame;
|
|
506
|
+
const hit = widgetAt(frame, event.x, event.y);
|
|
507
|
+
this.pointer = { x: event.x, y: event.y };
|
|
508
|
+
this.hover(hit?.widget ?? null);
|
|
509
|
+
// The wheel scrolls the innermost viewport under the pointer that can
|
|
510
|
+
// move, a line a notch, past any widget it shows there and whatever a
|
|
511
|
+
// drag captured. With none, nothing moved and there is nothing to paint.
|
|
512
|
+
if (event.type === "scroll_up" || event.type === "scroll_down") {
|
|
513
|
+
const lines = event.type === "scroll_down" ? 1 : -1;
|
|
514
|
+
const target = scrollTargetAt(frame, event.x, event.y, lines);
|
|
515
|
+
if (!target)
|
|
516
|
+
return;
|
|
517
|
+
target.scrollBy(lines);
|
|
518
|
+
this.source.refresh();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// [LAW:dataflow-not-control-flow] Same pipeline every event; the
|
|
522
|
+
// capture value and event type pick the target. mouse_down opens a
|
|
523
|
+
// capture, mouse_up closes it, mouse_move in between routes to the
|
|
524
|
+
// captured widget regardless of pointer position. A hit's origin puts the
|
|
525
|
+
// pressed cell at its own column and row, so every delivery is the same
|
|
526
|
+
// subtraction.
|
|
527
|
+
const useCapture = event.type === "mouse_move" || event.type === "mouse_up";
|
|
528
|
+
const target = (useCapture && this.captured ? relocate(frame, this.captured) : null) ??
|
|
529
|
+
(hit ? { widget: hit.widget, originX: event.x - hit.col, originY: event.y - hit.row } : null);
|
|
530
|
+
target?.widget.handleMouse({
|
|
531
|
+
...event,
|
|
532
|
+
x: event.x - target.originX,
|
|
533
|
+
y: event.y - target.originY,
|
|
534
|
+
over: drew(frame, target.widget, event.x, event.y),
|
|
535
|
+
});
|
|
536
|
+
if (event.type === "mouse_down")
|
|
537
|
+
this.captured = target;
|
|
538
|
+
else if (event.type === "mouse_up")
|
|
539
|
+
this.captured = null;
|
|
540
|
+
}
|
|
541
|
+
// [LAW:single-enforcer] The canonical setter on the widget is the only
|
|
542
|
+
// writer of `hovered`; this is the only caller, and only on a change.
|
|
543
|
+
hover(widget) {
|
|
544
|
+
if (widget === this.hoveredWidget)
|
|
545
|
+
return;
|
|
546
|
+
this.hoveredWidget?.setHovered(false);
|
|
547
|
+
widget?.setHovered(true);
|
|
548
|
+
this.hoveredWidget = widget;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// --- Helpers ---
|
|
552
|
+
// [LAW:one-source-of-truth] A captured widget's origin is read off the frame
|
|
553
|
+
// just painted, so a drag follows the widget if it moved since the press; one
|
|
554
|
+
// no longer painted is where the press found it.
|
|
555
|
+
function relocate(frame, target) {
|
|
556
|
+
const origin = originOf(frame, target.widget);
|
|
557
|
+
return origin ? { widget: target.widget, originX: origin.x, originY: origin.y } : target;
|
|
558
|
+
}
|
|
559
|
+
function decodeMouseFromCb(cb, x, y, isPress) {
|
|
560
|
+
const isMotion = (cb & 32) !== 0;
|
|
561
|
+
const isScroll = (cb & 64) !== 0;
|
|
562
|
+
const button = cb & 3;
|
|
563
|
+
const shift = !!(cb & 4);
|
|
564
|
+
const ctrl = !!(cb & 16); // bit 16 = ctrl in xterm encoding
|
|
565
|
+
if (isScroll) {
|
|
566
|
+
const type = button === 0 ? "scroll_up" : "scroll_down";
|
|
567
|
+
return { type, x, y, button: 0, shift, ctrl };
|
|
568
|
+
}
|
|
569
|
+
if (isMotion) {
|
|
570
|
+
return { type: "mouse_move", x, y, button, shift, ctrl };
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
type: isPress ? "mouse_down" : "mouse_up",
|
|
574
|
+
x,
|
|
575
|
+
y,
|
|
576
|
+
button,
|
|
577
|
+
shift,
|
|
578
|
+
ctrl,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function decodeCSI(params, final, bytes) {
|
|
582
|
+
// Handle modifier suffixes like `1;2A` (shift+up). We split params on `;`.
|
|
583
|
+
const parts = params.split(";");
|
|
584
|
+
const first = parts[0] ?? "";
|
|
585
|
+
const mod = parseInt(parts[1] ?? "", 10);
|
|
586
|
+
const { shift, ctrl, meta } = decodeModifier(mod);
|
|
587
|
+
if (final === "~") {
|
|
588
|
+
const name = CSI_TILDE_KEYS[first];
|
|
589
|
+
if (!name)
|
|
590
|
+
return { kind: "skip", bytes };
|
|
591
|
+
return {
|
|
592
|
+
kind: "key",
|
|
593
|
+
bytes,
|
|
594
|
+
event: new KeyEvent({ key: name, character: "", shift, ctrl, meta }),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const name = CSI_LETTER_KEYS[final];
|
|
598
|
+
if (!name)
|
|
599
|
+
return { kind: "skip", bytes };
|
|
600
|
+
// Special case: ESC[Z is shift+tab regardless of explicit modifier.
|
|
601
|
+
// [LAW:one-source-of-truth] `character` is the printable character the
|
|
602
|
+
// key would produce, or "" for non-printable / modifier-only events.
|
|
603
|
+
// The non-shifted tab path emits character: "" (Tab is named, not
|
|
604
|
+
// printed) — keep shift+tab consistent. Distinguish via key === "tab"
|
|
605
|
+
// + shift, not character.
|
|
606
|
+
if (final === "Z") {
|
|
607
|
+
return {
|
|
608
|
+
kind: "key",
|
|
609
|
+
bytes,
|
|
610
|
+
event: new KeyEvent({ key: "tab", character: "", shift: true, ctrl: false, meta: false }),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
kind: "key",
|
|
615
|
+
bytes,
|
|
616
|
+
event: new KeyEvent({ key: name, character: "", shift, ctrl, meta }),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function decodeModifier(mod) {
|
|
620
|
+
if (!Number.isFinite(mod) || mod <= 1) {
|
|
621
|
+
return { shift: false, ctrl: false, meta: false };
|
|
622
|
+
}
|
|
623
|
+
const bits = mod - 1;
|
|
624
|
+
return {
|
|
625
|
+
shift: (bits & 1) !== 0,
|
|
626
|
+
meta: (bits & 2) !== 0,
|
|
627
|
+
ctrl: (bits & 4) !== 0,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FocusManager — which widget has focus, over the widgets on the frame.
|
|
3
|
+
* [LAW:one-source-of-truth] single authority for which widget has focus.
|
|
4
|
+
* [LAW:dataflow-not-control-flow] focus transitions are observable state;
|
|
5
|
+
* widgets react to focus/blur events, the manager never skips dispatch.
|
|
6
|
+
*/
|
|
7
|
+
import type { InteractiveWidget, FocusManager, KeyEvent } from "./types.js";
|
|
8
|
+
import type { Unsubscribe } from "../core/subscription.js";
|
|
9
|
+
export declare class DefaultFocusManager implements FocusManager {
|
|
10
|
+
private readonly onScreen;
|
|
11
|
+
accessor currentWidget: InteractiveWidget | null;
|
|
12
|
+
private readonly changeHandlers;
|
|
13
|
+
/**
|
|
14
|
+
* `onScreen` is the widgets on the frame on screen, in document order.
|
|
15
|
+
*
|
|
16
|
+
* [LAW:one-source-of-truth] It is asked every time rather than kept: a list
|
|
17
|
+
* of the widgets held here would be a second picture of the frame, and
|
|
18
|
+
* wrong from the next paint.
|
|
19
|
+
*/
|
|
20
|
+
constructor(onScreen: () => readonly InteractiveWidget[]);
|
|
21
|
+
get current(): InteractiveWidget | null;
|
|
22
|
+
get widgets(): readonly InteractiveWidget[];
|
|
23
|
+
next(): void;
|
|
24
|
+
prev(): void;
|
|
25
|
+
focus(widget: InteractiveWidget): void;
|
|
26
|
+
/**
|
|
27
|
+
* Put focus where the frame just painted lets it rest: it stays on a widget
|
|
28
|
+
* the view drew (`drawn`) that can take it, and otherwise moves to the first
|
|
29
|
+
* one on screen that can, or to none.
|
|
30
|
+
*
|
|
31
|
+
* Drawn, not on screen: a widget cropped for the moment — the terminal
|
|
32
|
+
* shrank, a viewport scrolled, an overlay covers it — is still in the view,
|
|
33
|
+
* and keeps focus and whatever focus holds, like a half-typed filter.
|
|
34
|
+
*
|
|
35
|
+
* [LAW:single-enforcer] The one place focus follows the frame — onto the
|
|
36
|
+
* first widget when the app starts, off a widget that stopped being drawn
|
|
37
|
+
* or was disabled.
|
|
38
|
+
*/
|
|
39
|
+
settle(drawn: readonly InteractiveWidget[]): void;
|
|
40
|
+
onChange(handler: (current: InteractiveWidget | null) => void): Unsubscribe;
|
|
41
|
+
handleKey(event: KeyEvent): void;
|
|
42
|
+
private clear;
|
|
43
|
+
private setFocus;
|
|
44
|
+
private emitChange;
|
|
45
|
+
}
|