tickmarkr 1.83.0 → 1.84.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/dist/tui/cockpit/components.d.ts +28 -1
- package/dist/tui/cockpit/components.js +19 -3
- package/dist/tui/cockpit/layout.d.ts +75 -6
- package/dist/tui/cockpit/layout.js +97 -19
- package/dist/tui/cockpit/live.d.ts +14 -1
- package/dist/tui/cockpit/live.js +223 -29
- package/dist/tui/cockpit/pointer.d.ts +261 -0
- package/dist/tui/cockpit/pointer.js +610 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
- package/dist/tui/cockpit/run-cockpit.js +145 -27
- package/package.json +1 -1
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { dispatchRunKey, RUN_INPUT_BINDINGS, RUN_SIDE_RAIL_COLUMN_FLOOR, runPanelFocusOrder, } from "./keys.js";
|
|
2
|
+
import { FRAME_VIEWS, } from "./layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* THE pointer layer of the run cockpit. Three laws govern every line of it:
|
|
5
|
+
*
|
|
6
|
+
* 1. HIT-TESTING RESOLVES THROUGH THE PLAN. A cell becomes a target by being
|
|
7
|
+
* looked up in planFrame's own output — the plan the conformance oracle
|
|
8
|
+
* pins — and every offset inside it is planned too: the bands from
|
|
9
|
+
* `plan.regions`, which the item rows are a member of, and the rail's view
|
|
10
|
+
* rows from `plan.sidebar.viewRows`. Both are read, never reconstructed:
|
|
11
|
+
* this layer performs no geometric arithmetic at all, so there is no
|
|
12
|
+
* expression here for a plan change to leave behind. The plan is the whole
|
|
13
|
+
* input: nothing is passed beside it, so no caller can hand this layer a
|
|
14
|
+
* rectangle the plan did not produce. It owns no constant of the drawn
|
|
15
|
+
* chrome and imports none:
|
|
16
|
+
* it never measures the terminal, subtracts a border, re-derives a band or
|
|
17
|
+
* caches a rectangle. v1.83 deleted withBandGeometry for exactly that
|
|
18
|
+
* defect, and it does not return wearing a mouse.
|
|
19
|
+
* 2. EVERY POINTER ACTION IS A KEY'S TRANSITION. The pointer dispatches only
|
|
20
|
+
* keys through the one registry; it never manufactures focus, selection or
|
|
21
|
+
* prompt state beside the keyboard and then restores it afterward.
|
|
22
|
+
* 3. A POINTER ACTION IS NEVER ROUTED THROUGH WHATEVER SCOPE IS LIVE. A click
|
|
23
|
+
* means the target it hit, so the / prompt — a keyboard scope in which a
|
|
24
|
+
* digit is filter text and ⏎ applies a filter — is retired before any
|
|
25
|
+
* transition is dispatched. Otherwise clicking Gates would type "3". The
|
|
26
|
+
* wheel dispatches through that same normal roster but restores the prompt
|
|
27
|
+
* afterwards: a scroll names no target, so it may not retire one panel's
|
|
28
|
+
* prompt on its way to scrolling another.
|
|
29
|
+
* 4. WHERE THE POINTER IS RESTING IS DRAWN STATE, NOT A TRANSITION. It is the
|
|
30
|
+
* one piece of pointer state no key has an equivalent for, so it travels
|
|
31
|
+
* beside the transition rather than inside it — see `pointerRestingCell`.
|
|
32
|
+
*/
|
|
33
|
+
/** SGR-1006 (`ESC [ < b ; x ; y M|m`) — the one pointer reporting mode read. */
|
|
34
|
+
const SGR_POINTER_REPORT = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/gu;
|
|
35
|
+
/**
|
|
36
|
+
* The terminal reports no pointer at all until it is asked to, so the parser
|
|
37
|
+
* above reads nothing until these bytes are written. DECSET 1000 turns on normal
|
|
38
|
+
* tracking — presses, releases and the wheel. DECSET 1006 asks for them in SGR
|
|
39
|
+
* encoding: the one grammar `SGR_POINTER_REPORT` reads, and the only one that
|
|
40
|
+
* can state a cell past column 223 at all. DECSET 1003 widens that to motion
|
|
41
|
+
* with no button held — the only way a terminal ever says where the pointer is
|
|
42
|
+
* merely resting, and therefore the whole reason a hover highlight can exist
|
|
43
|
+
* at all. It is asked for last, so the widest tracking mode is the one asked
|
|
44
|
+
* for in the grammar already selected. The request lives beside the parser so a
|
|
45
|
+
* change to what is read cannot leave what is asked for behind.
|
|
46
|
+
*/
|
|
47
|
+
export const POINTER_TRACKING_ON = "\x1b[?1000h\x1b[?1006h\x1b[?1003h";
|
|
48
|
+
/** The same three modes turned off, in exact reverse of the order they were
|
|
49
|
+
* asked for — a terminal left tracking writes reports into whatever runs after
|
|
50
|
+
* the cockpit exits. */
|
|
51
|
+
export const POINTER_TRACKING_OFF = "\x1b[?1003l\x1b[?1006l\x1b[?1000l";
|
|
52
|
+
/**
|
|
53
|
+
* The signals that end a process where nothing else repays the loan: they run
|
|
54
|
+
* no `finally`, unwind no stack and unmount no renderer, so a surface killed by
|
|
55
|
+
* one would leave the operator's terminal reporting a pointer into whatever
|
|
56
|
+
* shell comes next. Listening for them is the only way to hand the modes back.
|
|
57
|
+
*
|
|
58
|
+
* The roster is every catchable POSIX terminator an interactive cockpit is
|
|
59
|
+
* actually killed by — ⌃C, a `kill`, a closed terminal, and ⌃\ — not a
|
|
60
|
+
* shortlist of the three that came to mind. A signal missing from here is a
|
|
61
|
+
* terminal left reporting, so the test that guards it names its own required
|
|
62
|
+
* set rather than reading this one back.
|
|
63
|
+
*/
|
|
64
|
+
export const POINTER_RELEASE_SIGNALS = [
|
|
65
|
+
"SIGINT",
|
|
66
|
+
"SIGTERM",
|
|
67
|
+
"SIGHUP",
|
|
68
|
+
"SIGQUIT",
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* The roster a given host can actually receive. SIGQUIT is a POSIX terminator
|
|
72
|
+
* with no Windows counterpart — libuv refuses the listener there — so the one
|
|
73
|
+
* platform that can never be killed by it is the one that does not listen for
|
|
74
|
+
* it. Every other platform listens for all four.
|
|
75
|
+
*/
|
|
76
|
+
function releaseSignals(host) {
|
|
77
|
+
return (host.platform ?? process.platform) === "win32"
|
|
78
|
+
? POINTER_RELEASE_SIGNALS.filter((signal) => signal !== "SIGQUIT")
|
|
79
|
+
: POINTER_RELEASE_SIGNALS;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* WHERE THE POINTER IS RESTING — the fourth law's state, and the only pointer
|
|
83
|
+
* state the keyboard has no equivalent for.
|
|
84
|
+
*
|
|
85
|
+
* It is drawn state and nothing else: no key produces it, no interaction
|
|
86
|
+
* transition carries it, no journal records it, nothing reads it back to decide
|
|
87
|
+
* anything, and no capture ever asks for it. So it travels BESIDE the
|
|
88
|
+
* transition `applyPointerReport` returns rather than inside it, which is what
|
|
89
|
+
* lets a resting pointer stay no transition at all — it moves no marker, opens
|
|
90
|
+
* no view and selects nothing.
|
|
91
|
+
*
|
|
92
|
+
* Both writers are in this file, and both are the terminal telling us something
|
|
93
|
+
* rather than a surface deciding something: every report states where the
|
|
94
|
+
* pointer now is, and the tracking loan's release states that the terminal has
|
|
95
|
+
* stopped saying. Release restores exactly the state that stands before the
|
|
96
|
+
* first report ever arrives, so a surface nobody has pointed at yet and a
|
|
97
|
+
* surface whose loan was repaid are the same surface.
|
|
98
|
+
*
|
|
99
|
+
* ponytail: one process drives one terminal and a terminal has one pointer, so
|
|
100
|
+
* this is one cell rather than a store per surface; make it surface-owned if a
|
|
101
|
+
* process ever paints two cockpits at once.
|
|
102
|
+
*/
|
|
103
|
+
let restingCell = null;
|
|
104
|
+
const restWatchers = new Set();
|
|
105
|
+
function reportPointerRest(cell) {
|
|
106
|
+
if (cell === null
|
|
107
|
+
? restingCell === null
|
|
108
|
+
: restingCell !== null && restingCell.column === cell.column
|
|
109
|
+
&& restingCell.row === cell.row)
|
|
110
|
+
return;
|
|
111
|
+
// Copied rather than aliased: a report object stays its reader's own.
|
|
112
|
+
restingCell = cell === null ? null : { column: cell.column, row: cell.row };
|
|
113
|
+
for (const watcher of restWatchers)
|
|
114
|
+
watcher();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The cell the pointer is resting on, or none. The same reference for as long
|
|
118
|
+
* as it has not moved, so a watcher redraws when the pointer moves and at no
|
|
119
|
+
* other time.
|
|
120
|
+
*/
|
|
121
|
+
export function pointerRestingCell() {
|
|
122
|
+
return restingCell;
|
|
123
|
+
}
|
|
124
|
+
/** Watch the resting cell. The returned call stops watching. */
|
|
125
|
+
export function watchPointerRest(watcher) {
|
|
126
|
+
restWatchers.add(watcher);
|
|
127
|
+
return () => {
|
|
128
|
+
restWatchers.delete(watcher);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* THE SESSION PANEL OVERRIDE — the rail columns the operator dragged the
|
|
133
|
+
* rail/body boundary to, this session only. Like the resting cell it is drawn
|
|
134
|
+
* state no key has an equivalent for, so it travels beside the transitions
|
|
135
|
+
* `applyPointerReport` returns rather than inside one: a drag moves no marker,
|
|
136
|
+
* opens no view and selects nothing, it re-PLANS. The override is an input to
|
|
137
|
+
* planFrame (`FrameState.railColumns`), never a bypass of it — the plan
|
|
138
|
+
* recomputes from the measured size plus this override, clamps it at the
|
|
139
|
+
* panels' readable floors, and the renderer draws the recomputed plan
|
|
140
|
+
* unmodified, so drawn still equals planned everywhere.
|
|
141
|
+
*
|
|
142
|
+
* It lives only in the running process: nothing writes it to disk, the
|
|
143
|
+
* journal, or anywhere else, and a relaunch starts with none — the default
|
|
144
|
+
* layout draws. The process boundary is marked by the tracking loan: a fresh
|
|
145
|
+
* `borrowPointerTracking` is a fresh session and clears whatever a previous
|
|
146
|
+
* session dragged to.
|
|
147
|
+
*/
|
|
148
|
+
let railOverride = null;
|
|
149
|
+
const overrideWatchers = new Set();
|
|
150
|
+
/** Set by a press on the panel boundary, released by the button's release. */
|
|
151
|
+
let boundaryDrag = false;
|
|
152
|
+
function reportRailOverride(columns) {
|
|
153
|
+
if (railOverride === columns)
|
|
154
|
+
return;
|
|
155
|
+
railOverride = columns;
|
|
156
|
+
for (const watcher of overrideWatchers)
|
|
157
|
+
watcher();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The rail columns the session dragged the boundary to, or none. The same
|
|
161
|
+
* reference for as long as it has not changed, so a watcher redraws when the
|
|
162
|
+
* drag moves the boundary and at no other time.
|
|
163
|
+
*/
|
|
164
|
+
export function sessionRailOverride() {
|
|
165
|
+
return railOverride;
|
|
166
|
+
}
|
|
167
|
+
/** Watch the session override. The returned call stops watching. */
|
|
168
|
+
export function watchSessionRailOverride(watcher) {
|
|
169
|
+
overrideWatchers.add(watcher);
|
|
170
|
+
return () => {
|
|
171
|
+
overrideWatchers.delete(watcher);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The session boundary the relaunch contract is stated against: the override
|
|
176
|
+
* and any drag in progress are gone, exactly as a new process starts. The
|
|
177
|
+
* production marker is the tracking loan — a fresh borrow is a fresh session.
|
|
178
|
+
*/
|
|
179
|
+
export function resetSessionRailOverride() {
|
|
180
|
+
boundaryDrag = false;
|
|
181
|
+
reportRailOverride(null);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Borrow pointer reporting, and return the one way to repay it.
|
|
185
|
+
*
|
|
186
|
+
* The loan is the whole lifecycle in one expression, so no caller can hold half
|
|
187
|
+
* of it. Only an interactive terminal is asked at all: writing mode requests
|
|
188
|
+
* into a pipe puts escape bytes in a capture rather than a mouse on a surface
|
|
189
|
+
* nobody is pointing at, so the non-tty and CI surfaces carry no enable
|
|
190
|
+
* sequence because none is ever written — not because a later guard strips one.
|
|
191
|
+
*
|
|
192
|
+
* Repayment is idempotent and reachable from every exit: the returned release
|
|
193
|
+
* for an ordinary return, the same release run from a `finally` or a renderer's
|
|
194
|
+
* cleanup for a thrown failure, and the signal listeners registered here for
|
|
195
|
+
* the exits that run neither. A listener repays and then re-raises its own
|
|
196
|
+
* signal with our listener removed, so the process still ends exactly the way
|
|
197
|
+
* the signal meant it to.
|
|
198
|
+
*/
|
|
199
|
+
export function borrowPointerTracking(terminal, host = process) {
|
|
200
|
+
// A fresh loan is a fresh session: the override a previous session dragged
|
|
201
|
+
// the panel boundary to dies with it — a relaunch draws the original layout.
|
|
202
|
+
resetSessionRailOverride();
|
|
203
|
+
if (terminal.isTTY !== true)
|
|
204
|
+
return () => { };
|
|
205
|
+
const listeners = [];
|
|
206
|
+
let lent = true;
|
|
207
|
+
const release = () => {
|
|
208
|
+
if (!lent)
|
|
209
|
+
return;
|
|
210
|
+
lent = false;
|
|
211
|
+
for (const [signal, listener] of listeners)
|
|
212
|
+
host.off(signal, listener);
|
|
213
|
+
// The terminal stops saying where the pointer is, so nothing is resting
|
|
214
|
+
// anywhere any more — and a frame drawn after this draws no highlight.
|
|
215
|
+
reportPointerRest(null);
|
|
216
|
+
terminal.write(POINTER_TRACKING_OFF);
|
|
217
|
+
};
|
|
218
|
+
for (const signal of releaseSignals(host)) {
|
|
219
|
+
const listener = () => {
|
|
220
|
+
release();
|
|
221
|
+
host.kill(host.pid, signal);
|
|
222
|
+
};
|
|
223
|
+
listeners.push([signal, listener]);
|
|
224
|
+
host.on(signal, listener);
|
|
225
|
+
}
|
|
226
|
+
// Asked for only once the repayment path stands: a loan taken before that
|
|
227
|
+
// could be one the signal listeners were never registered for.
|
|
228
|
+
terminal.write(POINTER_TRACKING_ON);
|
|
229
|
+
return release;
|
|
230
|
+
}
|
|
231
|
+
/** SGR-1006 encodes the wheel in bit 6 and pointer motion in bit 5. */
|
|
232
|
+
const WHEEL_BIT = 64;
|
|
233
|
+
const MOTION_BIT = 32;
|
|
234
|
+
/**
|
|
235
|
+
* The pointer reports carried by a chunk of terminal input. Bytes that are not
|
|
236
|
+
* a report are not pointer input and are left for whoever else reads the
|
|
237
|
+
* stream; a torn or malformed sequence simply reports nothing.
|
|
238
|
+
*/
|
|
239
|
+
export function parsePointerReports(bytes) {
|
|
240
|
+
const reports = [];
|
|
241
|
+
for (const match of bytes.matchAll(SGR_POINTER_REPORT)) {
|
|
242
|
+
const button = Number(match[1]);
|
|
243
|
+
// The terminal counts cells from one; the plan counts them from zero.
|
|
244
|
+
const column = Number(match[2]) - 1;
|
|
245
|
+
const row = Number(match[3]) - 1;
|
|
246
|
+
if (column < 0 || row < 0)
|
|
247
|
+
continue;
|
|
248
|
+
const action = (button & WHEEL_BIT) !== 0
|
|
249
|
+
? ((button & 1) === 0 ? "wheel-up" : "wheel-down")
|
|
250
|
+
: (button & MOTION_BIT) !== 0
|
|
251
|
+
? "move"
|
|
252
|
+
: match[4] === "m"
|
|
253
|
+
? "release"
|
|
254
|
+
: "press";
|
|
255
|
+
reports.push({ action, column, row, button });
|
|
256
|
+
}
|
|
257
|
+
return reports;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The longest suffix of `bytes` that is still on its way to being a report: an
|
|
261
|
+
* escape whose `M` or `m` has not arrived yet. A completed report never matches
|
|
262
|
+
* (it ends at its own terminator), so a carried tail can never be reported
|
|
263
|
+
* twice. ponytail: a tail longer than any real report is not one — it is
|
|
264
|
+
* dropped rather than accumulated, which is also what bounds this buffer.
|
|
265
|
+
*/
|
|
266
|
+
const TORN_POINTER_REPORT = /\x1b(?:\[(?:<[\d;]*)?)?$/u;
|
|
267
|
+
const MAX_TORN_REPORT_BYTES = 32;
|
|
268
|
+
function tornPointerTail(bytes) {
|
|
269
|
+
const torn = TORN_POINTER_REPORT.exec(bytes)?.[0] ?? "";
|
|
270
|
+
return torn.length > MAX_TORN_REPORT_BYTES ? "" : torn;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A reader over the input stream rather than over one chunk. The terminal
|
|
274
|
+
* writes bytes, not messages: a single report is free to arrive split across
|
|
275
|
+
* chunks, and chunk-at-a-time parsing silently drops every report that lands on
|
|
276
|
+
* a boundary. The reader carries the torn tail into the next chunk and reports
|
|
277
|
+
* only sequences that have completed.
|
|
278
|
+
*
|
|
279
|
+
* It also states what was left — the bytes of that chunk that belonged to no
|
|
280
|
+
* report, including none at all while a torn one is still arriving. Reports and
|
|
281
|
+
* keys share one stream, so this is the only place that can tell them apart:
|
|
282
|
+
* downstream, a whole report is an unnameable escape sequence and half a report
|
|
283
|
+
* is the single character it happens to end on, either of which would be read as
|
|
284
|
+
* a keystroke by whatever scope is live.
|
|
285
|
+
*/
|
|
286
|
+
export function createPointerReportReader() {
|
|
287
|
+
let carry = "";
|
|
288
|
+
const readComplete = (complete) => {
|
|
289
|
+
const tokens = [];
|
|
290
|
+
let offset = 0;
|
|
291
|
+
for (const match of complete.matchAll(SGR_POINTER_REPORT)) {
|
|
292
|
+
const index = match.index;
|
|
293
|
+
if (index > offset) {
|
|
294
|
+
tokens.push({ type: "keys", bytes: complete.slice(offset, index) });
|
|
295
|
+
}
|
|
296
|
+
const report = parsePointerReports(match[0])[0];
|
|
297
|
+
if (report !== undefined)
|
|
298
|
+
tokens.push({ type: "pointer", report });
|
|
299
|
+
offset = index + match[0].length;
|
|
300
|
+
}
|
|
301
|
+
if (offset < complete.length) {
|
|
302
|
+
tokens.push({ type: "keys", bytes: complete.slice(offset) });
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
tokens,
|
|
306
|
+
reports: tokens.flatMap((token) => token.type === "pointer" ? [token.report] : []),
|
|
307
|
+
keys: tokens.flatMap((token) => token.type === "keys" ? [token.bytes] : [])
|
|
308
|
+
.join(""),
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
const reader = ((chunk) => {
|
|
312
|
+
const bytes = carry + chunk;
|
|
313
|
+
carry = tornPointerTail(bytes);
|
|
314
|
+
const complete = carry.length > 0 ? bytes.slice(0, -carry.length) : bytes;
|
|
315
|
+
return readComplete(complete);
|
|
316
|
+
});
|
|
317
|
+
reader.flush = () => {
|
|
318
|
+
const pending = carry;
|
|
319
|
+
carry = "";
|
|
320
|
+
return readComplete(pending);
|
|
321
|
+
};
|
|
322
|
+
reader.pending = () => carry;
|
|
323
|
+
return reader;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The width the key registry resolves at for the bands THIS plan drew. The
|
|
327
|
+
* focus order follows the plan rather than a second reading of the terminal:
|
|
328
|
+
* between 64 and 79 columns the plan draws the rail at a width the key floor
|
|
329
|
+
* alone would deny it, and the click targets and the focus order have to agree
|
|
330
|
+
* about that band or a click would act on a panel the frame does not show.
|
|
331
|
+
*/
|
|
332
|
+
export function plannedKeyColumns(plan) {
|
|
333
|
+
return plan.band === "sidebar"
|
|
334
|
+
? Math.max(plan.size.columns, RUN_SIDE_RAIL_COLUMN_FLOOR)
|
|
335
|
+
: plan.size.columns;
|
|
336
|
+
}
|
|
337
|
+
/** Which planned band holds which focusable panel — the frame's own two. */
|
|
338
|
+
const REGION_PANELS = {
|
|
339
|
+
rail: "VIEWS",
|
|
340
|
+
body: "CONTENT",
|
|
341
|
+
// The item rows are the body band's own nested band: a cell on them is a cell
|
|
342
|
+
// in the body, and focusing it focuses the panel the body draws.
|
|
343
|
+
items: "CONTENT",
|
|
344
|
+
};
|
|
345
|
+
/** The tightest planned region containing a cell — regions nest (the item rows
|
|
346
|
+
* refine the body band, the caption the header's row), so the smallest one owns
|
|
347
|
+
* the cell. */
|
|
348
|
+
function plannedRegionAt(plan, cell) {
|
|
349
|
+
let hit;
|
|
350
|
+
for (const region of plan.regions) {
|
|
351
|
+
if (!contains(region, cell))
|
|
352
|
+
continue;
|
|
353
|
+
if (hit === undefined || region.rows * region.columns < hit.rows * hit.columns) {
|
|
354
|
+
hit = region;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return hit;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* What the plan says is under a cell. The rail's view rows are
|
|
361
|
+
* `plan.sidebar.viewRows` — frame rows planFrame itself computed, where the
|
|
362
|
+
* label row was decided — so the menu's shape is looked up rather than
|
|
363
|
+
* reconstructed from `menuRows` out here. The body's item rows are a planned
|
|
364
|
+
* band of their own, so the tightest-region lookup lands on them directly and
|
|
365
|
+
* the first item's offset is the region's own row. The view strip the plan draws
|
|
366
|
+
* instead of a rail below 64 columns is one span with no per-view columns in it,
|
|
367
|
+
* so nothing here invents any: a strip cell resolves to the strip band and no
|
|
368
|
+
* further.
|
|
369
|
+
*/
|
|
370
|
+
export function resolvePointerTarget(plan, cell) {
|
|
371
|
+
const region = plannedRegionAt(plan, cell);
|
|
372
|
+
if (region === undefined)
|
|
373
|
+
return undefined;
|
|
374
|
+
if (region.id === "rail" && plan.sidebar !== null && plan.tab === "watch") {
|
|
375
|
+
const rows = plan.sidebar.viewRows;
|
|
376
|
+
const view = FRAME_VIEWS.findIndex((name) => rows[name] === cell.row);
|
|
377
|
+
return view < 0 ? { region } : { region, view };
|
|
378
|
+
}
|
|
379
|
+
if (region.id === "items")
|
|
380
|
+
return { region, row: cell.row - region.row };
|
|
381
|
+
return { region };
|
|
382
|
+
}
|
|
383
|
+
function contains(region, cell) {
|
|
384
|
+
return cell.row >= region.row
|
|
385
|
+
&& cell.row < region.row + region.rows
|
|
386
|
+
&& cell.column >= region.column
|
|
387
|
+
&& cell.column < region.column + region.columns;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The focus index a target's band carries at the width the plan drew — the same
|
|
391
|
+
* order the frame paints its focus ring from, so a click can never focus a
|
|
392
|
+
* panel the frame does not show as focusable.
|
|
393
|
+
*/
|
|
394
|
+
export function pointerFocusPanel(plan, target) {
|
|
395
|
+
const panel = REGION_PANELS[target.region.id];
|
|
396
|
+
if (panel === undefined)
|
|
397
|
+
return undefined;
|
|
398
|
+
const index = runPanelFocusOrder(plannedKeyColumns(plan)).indexOf(panel);
|
|
399
|
+
return index < 0 ? undefined : index;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The item a cell acts on — THE resolution, shared by every pointer path.
|
|
403
|
+
* A click reads it to decide what to select, and a hover highlight reads the
|
|
404
|
+
* same call to decide what to mark, so the highlight cannot name a row a click
|
|
405
|
+
* at that cell would miss: there is one answer, not two agreeing ones. A cell
|
|
406
|
+
* the plan does not place on an item row, and an item row the paint drew
|
|
407
|
+
* nothing into, are both nothing here rather than a nearest guess.
|
|
408
|
+
*/
|
|
409
|
+
export function pointerRowAt(surface, cell) {
|
|
410
|
+
const target = resolvePointerTarget(surface.plan, cell);
|
|
411
|
+
return target?.row === undefined ? undefined : surface.drawnRowIds[target.row];
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The frame's one panel boundary: the rail's own last column, the grab cell
|
|
415
|
+
* beside the body's border. The body's border cell itself stays a click
|
|
416
|
+
* target of the body panel — a press there retires the prompt and focuses
|
|
417
|
+
* content exactly as the keyboard's grammar advertises — so the drag handle
|
|
418
|
+
* is the cell this side of it — on the rows the plan gives no other target.
|
|
419
|
+
* A view row's last column already belongs to the view itself, so the handle
|
|
420
|
+
* yields those rows to the plan's own resolution. Read off the plan's own
|
|
421
|
+
* regions like every other target; a band with no rail has no boundary.
|
|
422
|
+
*/
|
|
423
|
+
function panelBoundaryCell(plan, cell) {
|
|
424
|
+
if (plan.band !== "sidebar")
|
|
425
|
+
return false;
|
|
426
|
+
const rail = plan.regions.find((region) => region.id === "rail");
|
|
427
|
+
if (rail === undefined
|
|
428
|
+
|| cell.column !== rail.column + rail.columns - 1
|
|
429
|
+
|| cell.row < rail.row
|
|
430
|
+
|| cell.row >= rail.row + rail.rows) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
// A view row's cell is the view's own click target wherever in the rail the
|
|
434
|
+
// plan puts it — the grab column included (the first law: hit-testing
|
|
435
|
+
// resolves through the plan, and the plan resolves that cell to the view).
|
|
436
|
+
// The handle owns the rail's remaining rows; at the readable floor the
|
|
437
|
+
// rail's last column sits on the final letter of a drawn view name, and a
|
|
438
|
+
// press there is still that view's number key, not a grab.
|
|
439
|
+
return plan.sidebar === null || plan.tab !== "watch" || FRAME_VIEWS.every((name) => plan.sidebar.viewRows[name] !== cell.row);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* The transition a pointer report makes, or undefined when nothing owns the
|
|
443
|
+
* cell. A click on a view name is that view's own number key; a click on the
|
|
444
|
+
* row already marked is ⏎; moving between panels is the advertised dive/back
|
|
445
|
+
* grammar; and a wheel is ↑↓ only when the panel under it already owns ↑↓.
|
|
446
|
+
* An off-focus wheel is not an action: borrowing focus or prompt scope and then
|
|
447
|
+
* restoring it would create a frame no advertised key sequence can reach.
|
|
448
|
+
*
|
|
449
|
+
* A drag of the panel boundary is the exception that proves the second law:
|
|
450
|
+
* it is no transition at all. The press landing on the boundary grabs it; each
|
|
451
|
+
* move hands the plan a new input through the session override; the release
|
|
452
|
+
* lets go — and when the release never arrives because the pointer was let go
|
|
453
|
+
* outside the window, the first no-button motion says so in its stead, so no
|
|
454
|
+
* latch outlives the drag it latched. The interaction comes back untouched —
|
|
455
|
+
* the drag re-plans the frame rather than moving anything inside it.
|
|
456
|
+
*/
|
|
457
|
+
export function applyPointerReport(report, surface) {
|
|
458
|
+
// Every report states where the pointer now is, whatever else it does or does
|
|
459
|
+
// not do — a move above all, whose whole content is that. This is the one
|
|
460
|
+
// call the live delivery makes for every report it receives, so the resting
|
|
461
|
+
// cell a frame draws its highlight from is fed by the production input path
|
|
462
|
+
// rather than by anything a caller supplies beside it.
|
|
463
|
+
reportPointerRest(report);
|
|
464
|
+
if (report.action === "press" && panelBoundaryCell(surface.plan, report)) {
|
|
465
|
+
boundaryDrag = true;
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
if (boundaryDrag) {
|
|
469
|
+
// A drag-motion — a move with a button still held — is the drag's only
|
|
470
|
+
// content. The requested rail width is where the rail's last column was
|
|
471
|
+
// dragged to; the plan owns the floors and clamps it there, so a drag
|
|
472
|
+
// past a panel's readable floor leaves the panel at the floor rather
|
|
473
|
+
// than collapsing it.
|
|
474
|
+
if (report.action === "move"
|
|
475
|
+
&& (report.button === undefined || (report.button & 3) !== 3)) {
|
|
476
|
+
reportRailOverride(report.column + 1);
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
// Every other report ENDS the drag rather than feeding it or being
|
|
480
|
+
// swallowed by it. A release lets go. A no-button motion is the terminal
|
|
481
|
+
// saying the button is already up — the only word of the release that
|
|
482
|
+
// never arrives when the pointer is let go outside the window, and the
|
|
483
|
+
// report that would otherwise keep resizing the panel on a bare hover
|
|
484
|
+
// for the rest of the session. And anything else — a press, the wheel —
|
|
485
|
+
// is a fresh action a dead latch may not eat: the latch dies with the
|
|
486
|
+
// drag and the report takes its ordinary route.
|
|
487
|
+
boundaryDrag = false;
|
|
488
|
+
if (report.action === "release")
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
return pointerTransition(report, surface);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The transition alone, with no report of where the pointer now is. Asking what
|
|
495
|
+
* a press WOULD do is not the pointer coming to rest anywhere, so the hover
|
|
496
|
+
* resolution below reaches the click's own route through here rather than
|
|
497
|
+
* through the entry point that also moves the resting cell.
|
|
498
|
+
*/
|
|
499
|
+
function pointerTransition(report, surface) {
|
|
500
|
+
const target = resolvePointerTarget(surface.plan, report);
|
|
501
|
+
if (target === undefined)
|
|
502
|
+
return undefined;
|
|
503
|
+
const panel = pointerFocusPanel(surface.plan, target);
|
|
504
|
+
const columns = plannedKeyColumns(surface.plan);
|
|
505
|
+
const focusOrder = runPanelFocusOrder(columns);
|
|
506
|
+
const focusedPanel = (state) => state.panel % focusOrder.length;
|
|
507
|
+
const dispatch = (event, from) => dispatchRunKey(event, from, RUN_INPUT_BINDINGS, surface.rowIds, columns);
|
|
508
|
+
if (report.action === "wheel-up" || report.action === "wheel-down") {
|
|
509
|
+
if (panel === undefined || focusedPanel(surface.interaction) !== panel) {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
return dispatch({
|
|
513
|
+
input: "",
|
|
514
|
+
key: report.action === "wheel-up"
|
|
515
|
+
? { upArrow: true }
|
|
516
|
+
: { downArrow: true },
|
|
517
|
+
}, surface.interaction);
|
|
518
|
+
}
|
|
519
|
+
if (report.action !== "press")
|
|
520
|
+
return undefined;
|
|
521
|
+
// A click is outside the prompt's input class. Apply the prompt through its
|
|
522
|
+
// own advertised Enter before dispatching the click's advertised route.
|
|
523
|
+
let state = surface.interaction;
|
|
524
|
+
if (state.filterPrompt) {
|
|
525
|
+
const applied = dispatch({ input: "", key: { return: true } }, state);
|
|
526
|
+
if (applied === undefined)
|
|
527
|
+
return undefined;
|
|
528
|
+
state = applied;
|
|
529
|
+
}
|
|
530
|
+
if (target.view !== undefined) {
|
|
531
|
+
// Number keys are one-based positions in the same rail order.
|
|
532
|
+
return dispatch({ input: String(target.view + 1), key: {} }, state);
|
|
533
|
+
}
|
|
534
|
+
// Focus is never assigned. Dive or back through the advertised grammar until
|
|
535
|
+
// the target panel owns the keyboard, including closing an open detail before
|
|
536
|
+
// backing from content to the rail.
|
|
537
|
+
if (panel !== undefined) {
|
|
538
|
+
for (let attempts = 0; focusedPanel(state) !== panel && attempts < 2; attempts += 1) {
|
|
539
|
+
const next = dispatch(panel === focusOrder.indexOf("CONTENT")
|
|
540
|
+
? { input: "", key: { return: true } }
|
|
541
|
+
: { input: "", key: { leftArrow: true } }, state);
|
|
542
|
+
if (next === undefined || next === state)
|
|
543
|
+
return undefined;
|
|
544
|
+
state = next;
|
|
545
|
+
}
|
|
546
|
+
if (focusedPanel(state) !== panel)
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
if (target.row === undefined)
|
|
550
|
+
return state;
|
|
551
|
+
// Diving from a mismatched rail marker may legitimately open a different
|
|
552
|
+
// view. The clicked row belonged to the old committed frame, so no stale row
|
|
553
|
+
// transition follows that key-owned view change.
|
|
554
|
+
if (state.activeView !== surface.interaction.activeView)
|
|
555
|
+
return state;
|
|
556
|
+
const id = pointerRowAt(surface, report);
|
|
557
|
+
const targetIndex = id === undefined ? -1 : surface.rowIds.indexOf(id);
|
|
558
|
+
if (id === undefined || targetIndex < 0)
|
|
559
|
+
return state;
|
|
560
|
+
if (id === state.selection) {
|
|
561
|
+
return dispatch({ input: "", key: { return: true } }, state) ?? state;
|
|
562
|
+
}
|
|
563
|
+
// A row click is the same finite ↑↓ journey from the current marker. Work on
|
|
564
|
+
// a candidate state so an unreachable target causes no partial transition.
|
|
565
|
+
let candidate = state;
|
|
566
|
+
for (let attempts = 0; attempts <= surface.rowIds.length; attempts += 1) {
|
|
567
|
+
if (candidate.selection === id)
|
|
568
|
+
return candidate;
|
|
569
|
+
const currentIndex = candidate.selection === null
|
|
570
|
+
? -1
|
|
571
|
+
: surface.rowIds.indexOf(candidate.selection);
|
|
572
|
+
const next = dispatch({
|
|
573
|
+
input: "",
|
|
574
|
+
key: currentIndex > targetIndex
|
|
575
|
+
? { upArrow: true }
|
|
576
|
+
: { downArrow: true },
|
|
577
|
+
}, candidate);
|
|
578
|
+
if (next === undefined || next === candidate)
|
|
579
|
+
return undefined;
|
|
580
|
+
candidate = next;
|
|
581
|
+
}
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The item a hover highlight marks: the one a click at that cell would ACT ON.
|
|
586
|
+
*
|
|
587
|
+
* `pointerRowAt` answers a smaller question — which drawn row the plan places
|
|
588
|
+
* under the cell — and a click is not only that lookup. It is a route: the /
|
|
589
|
+
* prompt applied, focus dived or backed to the panel under the pointer, and only
|
|
590
|
+
* then the row. Any of those may legitimately land somewhere else, the plainest
|
|
591
|
+
* case is the rail marker standing on a view the body is not drawing: the click
|
|
592
|
+
* that dives into content opens the MARKED view and selects no row at all, while
|
|
593
|
+
* the rows under the pointer belong to the view being left behind.
|
|
594
|
+
*
|
|
595
|
+
* So the highlight is not decided by the lookup. It is decided by running the
|
|
596
|
+
* click's own transition and asking what it settled on — the same
|
|
597
|
+
* `pointerTransition` the live report path dispatches through, on the same
|
|
598
|
+
* surface, so an answer these two could disagree about is not expressible. A
|
|
599
|
+
* click that acts on something else, or on nothing, lights nothing up.
|
|
600
|
+
*
|
|
601
|
+
* It reports no rest and returns no state: asking is not pointing and not
|
|
602
|
+
* clicking, so the surface is exactly as it was.
|
|
603
|
+
*/
|
|
604
|
+
export function pointerHoverRow(surface, cell) {
|
|
605
|
+
const id = pointerRowAt(surface, cell);
|
|
606
|
+
if (id === undefined)
|
|
607
|
+
return undefined;
|
|
608
|
+
const clicked = pointerTransition({ action: "press", ...cell }, surface);
|
|
609
|
+
return clicked?.selection === id ? id : undefined;
|
|
610
|
+
}
|
|
@@ -7,7 +7,11 @@ import { type FramePlan, type FrameRegion, type FrameState, type FrameCockpitLay
|
|
|
7
7
|
export { deriveRunCockpitData } from "./derive.js";
|
|
8
8
|
export type { RunCockpitData } from "./derive.js";
|
|
9
9
|
export { PANEL_CHROME_ROWS } from "./components.js";
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The width focus and key routing resolve at, from a measured width — the form
|
|
12
|
+
* used before a plan exists. Once one does, `plannedKeyColumns` (pointer.ts)
|
|
13
|
+
* reads the same answer off the bands the plan actually drew.
|
|
14
|
+
*/
|
|
11
15
|
export declare function runKeyColumns(columns: number): number;
|
|
12
16
|
/**
|
|
13
17
|
* How many rows one line of body text occupies once the renderer wraps it into
|
|
@@ -25,11 +29,25 @@ export declare function panelRows(lines: readonly string[], columns: number): nu
|
|
|
25
29
|
* identities assigned by the production journal derivation.
|
|
26
30
|
*/
|
|
27
31
|
export declare function deriveRunViewRows(data: RunCockpitData, viewId: RunViewId, filterQuery?: string): readonly JournalRow[];
|
|
32
|
+
/**
|
|
33
|
+
* The identities the body's rows carry in the order the body draws them — the
|
|
34
|
+
* same window `SizedJournalPanel` paints, so a hit on a body row resolves to
|
|
35
|
+
* the row an operator is looking at rather than to a position in a collection
|
|
36
|
+
* the frame scrolled past. A body that draws no selectable list (the overview,
|
|
37
|
+
* the journal tail, an open detail, the help overlay, the decisions tab) owns
|
|
38
|
+
* no rows here.
|
|
39
|
+
*/
|
|
40
|
+
export declare function drawnRunViewRowIds(data: RunCockpitData, interaction: RunInteractionState, bodyRows: number): readonly string[];
|
|
28
41
|
/** What each drawn region draws: the resolved budgets its panels consume. */
|
|
29
42
|
export type RegionContent = {
|
|
30
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Where the body band's item rows are drawn and how many there are: the
|
|
45
|
+
* plan's own `items` region, carried here unmodified. This file subtracts no
|
|
46
|
+
* chrome to obtain it — planFrame plans it, the paint draws its list at it,
|
|
47
|
+
* and hit resolution (pointer.ts) reads the same region off the same plan.
|
|
48
|
+
*/
|
|
49
|
+
readonly items: FrameRegion;
|
|
31
50
|
readonly journal: number;
|
|
32
|
-
readonly bodyColumns: number;
|
|
33
51
|
readonly sideRails: boolean;
|
|
34
52
|
readonly stats: FrameCockpitLayout["stats"]["mode"] | "none";
|
|
35
53
|
readonly progressBar: boolean;
|
|
@@ -53,20 +71,33 @@ export type PlannedRunCockpitFrame = {
|
|
|
53
71
|
readonly keyEntries: readonly KeybarEntry[];
|
|
54
72
|
};
|
|
55
73
|
/** Resolve every draw-time input; the plan is planFrame's own return, consumed unmodified. */
|
|
56
|
-
export declare function planRunCockpitFrame({ data, columns, rows, interaction, keyProjection, }: {
|
|
74
|
+
export declare function planRunCockpitFrame({ data, columns, rows, interaction, keyProjection, railColumns, }: {
|
|
57
75
|
data: RunCockpitData;
|
|
58
76
|
columns: number;
|
|
59
77
|
rows?: number;
|
|
60
78
|
interaction?: RunInteractionState;
|
|
61
79
|
keyProjection?: RunKeyProjection;
|
|
80
|
+
/**
|
|
81
|
+
* The session's panel resize override, handed to planFrame as an input —
|
|
82
|
+
* never a bypass of it: the plan recomputes from the measured size plus this
|
|
83
|
+
* override and what is drawn is what the plan returns.
|
|
84
|
+
*/
|
|
85
|
+
railColumns?: number;
|
|
62
86
|
}): PlannedRunCockpitFrame;
|
|
63
87
|
/** The public surface always enters paint through one measured plan. */
|
|
64
|
-
export declare function RunCockpitFrame({ data, columns, rows, interaction, keyProjection, }: {
|
|
88
|
+
export declare function RunCockpitFrame({ data, columns, rows, interaction, keyProjection, onCommittedFrame, }: {
|
|
65
89
|
data: RunCockpitData;
|
|
66
90
|
columns: number;
|
|
67
91
|
rows?: number;
|
|
68
92
|
interaction?: RunInteractionState;
|
|
69
93
|
keyProjection?: RunKeyProjection;
|
|
94
|
+
/**
|
|
95
|
+
* The renderer's commit seam: once this frame is committed, the exact plan
|
|
96
|
+
* and inputs it was drawn from are handed back, so an input surface can
|
|
97
|
+
* resolve its hits against the frame on the screen rather than against a
|
|
98
|
+
* plan it derived for itself. What nothing has painted yet has no geometry.
|
|
99
|
+
*/
|
|
100
|
+
onCommittedFrame?: (planned: PlannedRunCockpitFrame, data: RunCockpitData) => void;
|
|
70
101
|
}): ReactElement;
|
|
71
102
|
/**
|
|
72
103
|
* Assert the plan's tiled extents, the bytes production paints, and — given
|