@uniflowed/tui 0.0.0-alpha.18

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/mouse.js ADDED
@@ -0,0 +1,333 @@
1
+ // @flow
2
+ //
3
+ // The mouse: what a terminal reports, and what a handler is given.
4
+ //
5
+ // A terminal says nothing about the mouse until it is asked to. The ask is a
6
+ // set of private modes (`terminal.js` writes them), and what comes back is an
7
+ // escape sequence in the middle of the same byte stream the keys arrive on —
8
+ // so this module is the grammar of that sequence, and `keys.js` is where the
9
+ // stream is split between the two.
10
+ //
11
+ // # SGR-1006, and only SGR-1006
12
+ //
13
+ // The original encoding (`ESC [ M` and three bytes) adds 32 to each coordinate
14
+ // so that they are printable, which stops working at column 223 — on a wide
15
+ // terminal it reports a position that is not where the pointer is. The 1006
16
+ // extension writes the numbers as decimal parameters and has no limit, and it
17
+ // also distinguishes a release from a press, which the original does not: a
18
+ // terminal in the old mode reports button 3 for *every* release, so an
19
+ // application cannot tell which button was let go.
20
+ //
21
+ // Both matter here, so this decoder accepts the extension only. A terminal
22
+ // that ignored `?1006h` reports the old form, and those bytes are recognised
23
+ // and dropped rather than decoded — see {@link legacyReportLength}. Dropping
24
+ // them is deliberate: decoding a position that is wrong past column 223 is
25
+ // worse than reporting no mouse at all, and letting the three raw bytes
26
+ // through would deliver them as keys, which is how a click on such a terminal
27
+ // types random characters into an `Input`.
28
+ //
29
+ // # Names are OpenTUI's, and so is the shape of the event
30
+ //
31
+ // `"down"`, `"up"`, `"move"`, `"drag"`, `"drag-end"`, `"drop"`, `"over"`,
32
+ // `"out"`, `"scroll"` — OpenTUI's nine `event.type` values, delivered to
33
+ // `onMouseDown`, `onMouseUp` and the rest of its handler names, bubbling from
34
+ // the node under the pointer up through its parents. A component written
35
+ // against OpenTUI's interaction page behaves the same way here.
36
+ //
37
+ // `preventDefault()` is here now, and it is worth saying what it prevents,
38
+ // because a method that suppressed nothing would be a promise rather than a
39
+ // method — which is why this module did not have one until there was a default
40
+ // to suppress. There is exactly one: a left press clears the selection and, if
41
+ // it landed on selectable text, starts a new one. A handler that calls
42
+ // `preventDefault()` on that `down` keeps the selection the reader already
43
+ // had and starts none, which is what a box that does its own thing with a
44
+ // drag — a slider, a canvas, a splitter — needs in order not to leave a
45
+ // highlight behind it. Nothing else this renderer does to a mouse event can be
46
+ // prevented, and no other event type has anything to prevent.
47
+ //
48
+ // It is not a severity of `stopPropagation()`, the same way it is not one for
49
+ // a key: one of them decides whether anybody else sees the event, the other
50
+ // decides whether the renderer acts on it.
51
+
52
+ /** OpenTUI's nine mouse event types. */
53
+ export type MouseEventType =
54
+ | "down"
55
+ | "up"
56
+ | "move"
57
+ | "drag"
58
+ | "drag-end"
59
+ | "drop"
60
+ | "over"
61
+ | "out"
62
+ | "scroll";
63
+
64
+ /** Which way a wheel turned. */
65
+ export type ScrollDirection = "up" | "down" | "left" | "right";
66
+
67
+ /** One turn of the wheel: which way, and how far. */
68
+ export type Scroll = {
69
+ readonly direction: ScrollDirection,
70
+ /** Notches. A terminal reports one report per notch, so this is always 1. */
71
+ readonly delta: number,
72
+ };
73
+
74
+ /**
75
+ * The three buttons a terminal can report, by the numbers it reports them as.
76
+ *
77
+ * A terminal has no notion of a fourth button or of a chord: the two low bits
78
+ * of its report hold one of these three and a fourth value meaning "none",
79
+ * which is why {@link MouseEvent.button} is nullable rather than being a
80
+ * fourth constant here.
81
+ */
82
+ export const MouseButton: {
83
+ readonly LEFT: number,
84
+ readonly MIDDLE: number,
85
+ readonly RIGHT: number,
86
+ } = Object.freeze({ LEFT: 0, MIDDLE: 1, RIGHT: 2 });
87
+
88
+ /**
89
+ * One mouse report, as the handler on a node sees it.
90
+ *
91
+ * `x` and `y` are cells of the frame, counted from zero at the top left — the
92
+ * same coordinates layout writes onto a node, so a handler can compare them
93
+ * with a node's geometry without converting. The terminal counts from one and
94
+ * that is converted here, once.
95
+ *
96
+ * `target`, `currentTarget` and `source` are the `id` prop of a box rather
97
+ * than the box itself. This package has no public node type — the tree is
98
+ * `internal/tree.js` precisely so that nothing outside can hold a node across
99
+ * a commit and read stale geometry off it — so what an event can carry is the
100
+ * name the application gave the box. A box with no `id` reports `null`, which
101
+ * is the right answer for the common case: a handler already knows which node
102
+ * it is on, because it is its own closure.
103
+ */
104
+ export type MouseEvent = {
105
+ /** Which of the two things a terminal's byte stream carries. */
106
+ readonly kind: "mouse",
107
+ readonly type: MouseEventType,
108
+ /**
109
+ * The button, as {@link MouseButton} names them, or `null`.
110
+ *
111
+ * `null` for a wheel report and for motion with nothing held down: both are
112
+ * encoded by the terminal as "no button", and reporting a left button for
113
+ * them would make `event.button === MouseButton.LEFT` true for a plain
114
+ * hover.
115
+ */
116
+ readonly button: number | null,
117
+ /** The cell under the pointer, counted from zero. */
118
+ readonly x: number,
119
+ readonly y: number,
120
+ readonly ctrl: boolean,
121
+ readonly shift: boolean,
122
+ readonly meta: boolean,
123
+ /** The bytes this arrived as. */
124
+ readonly raw: string,
125
+ /** The wheel, on a `"scroll"` event, and `null` on every other. */
126
+ readonly scroll: Scroll | null,
127
+ /** The `id` of the topmost box under the pointer. */
128
+ target: string | null,
129
+ /** The `id` of the box whose handler is running; changes as it bubbles. */
130
+ currentTarget: string | null,
131
+ /**
132
+ * The `id` of the box a drag started on.
133
+ *
134
+ * Set on `"drag"`, `"drag-end"` and `"drop"`, and `null` otherwise. It is
135
+ * what makes a drop useful: the box that was dropped *on* receives the
136
+ * event, and this says what was dropped.
137
+ */
138
+ source: string | null,
139
+ /** Stop the event reaching this node's ancestors. */
140
+ stopPropagation(): void,
141
+ /**
142
+ * Keep the renderer from doing its own thing with this event.
143
+ *
144
+ * On a left `"down"` that is the selection: the one the reader had is kept,
145
+ * and no new one begins under this press. Every other event type has no
146
+ * default, so calling this on one is harmless and does nothing.
147
+ */
148
+ preventDefault(): void,
149
+ /** Whether `stopPropagation()` was called. */
150
+ propagationStopped: boolean,
151
+ /** Whether `preventDefault()` was called. */
152
+ defaultPrevented: boolean,
153
+ };
154
+
155
+ /** The fields a decoded report carries before routing fills the rest in. */
156
+ type MouseFields = {
157
+ type: MouseEventType,
158
+ button: number | null,
159
+ x: number,
160
+ y: number,
161
+ ctrl: boolean,
162
+ shift: boolean,
163
+ meta: boolean,
164
+ raw: string,
165
+ scroll?: Scroll | null,
166
+ };
167
+
168
+ /** Build a mouse event with its propagation flag wired up. */
169
+ export function mouseEvent(fields: MouseFields): MouseEvent {
170
+ const event: MouseEvent = {
171
+ kind: "mouse",
172
+ type: fields.type,
173
+ button: fields.button,
174
+ x: fields.x,
175
+ y: fields.y,
176
+ ctrl: fields.ctrl,
177
+ shift: fields.shift,
178
+ meta: fields.meta,
179
+ raw: fields.raw,
180
+ scroll: fields.scroll ?? null,
181
+ target: null,
182
+ currentTarget: null,
183
+ source: null,
184
+ propagationStopped: false,
185
+ defaultPrevented: false,
186
+ stopPropagation() {
187
+ event.propagationStopped = true;
188
+ },
189
+ preventDefault() {
190
+ event.defaultPrevented = true;
191
+ },
192
+ };
193
+ return event;
194
+ }
195
+
196
+ /**
197
+ * The same report again, as another type.
198
+ *
199
+ * `"over"` and `"out"` are not reported by a terminal; they are what the
200
+ * renderer says when the topmost node under the pointer changed, and they
201
+ * carry the position and modifiers of the report that moved it. Deriving them
202
+ * rather than synthesising a bare event is what keeps `event.ctrl` true for
203
+ * the `"over"` that a Ctrl-drag caused.
204
+ */
205
+ export function derive(from: MouseEvent, type: MouseEventType): MouseEvent {
206
+ return mouseEvent({
207
+ type,
208
+ button: from.button,
209
+ x: from.x,
210
+ y: from.y,
211
+ ctrl: from.ctrl,
212
+ shift: from.shift,
213
+ meta: from.meta,
214
+ raw: from.raw,
215
+ scroll: from.scroll,
216
+ });
217
+ }
218
+
219
+ /** Which bit of a terminal's button byte means what. */
220
+ const SHIFT = 4;
221
+ const META = 8;
222
+ const CTRL = 16;
223
+ const MOTION = 32;
224
+ const WHEEL = 64;
225
+ /** The two low bits when no button is down. */
226
+ const NO_BUTTON = 3;
227
+
228
+ /** The wheel directions, in the order the two low bits name them. */
229
+ const WHEEL_DIRECTIONS: $ReadOnlyArray<ScrollDirection> = ["up", "down", "left", "right"];
230
+
231
+ /**
232
+ * Decode one SGR-1006 report: `ESC [ < button ; column ; row M` or `… m`.
233
+ *
234
+ * `start` must be the `ESC`, and the caller must already have established that
235
+ * `[` and `<` follow. Returns `null` for anything that is not a complete
236
+ * report, which includes one cut in half by the end of a chunk — the caller
237
+ * then treats the bytes as it treats any other unfinished sequence.
238
+ *
239
+ * The final byte is the whole of the press/release distinction: `M` is a
240
+ * press or a motion, `m` is a release, and the button bits say which button
241
+ * in both cases. That is the half of this encoding the original does not
242
+ * have.
243
+ */
244
+ export function decodeMouse(
245
+ input: string,
246
+ start: number,
247
+ ): { event: MouseEvent, length: number } | null {
248
+ let cursor = start + 3;
249
+ let parameters = "";
250
+ while (cursor < input.length && /[0-9;]/.test(input[cursor])) {
251
+ parameters += input[cursor];
252
+ cursor += 1;
253
+ }
254
+ const final = input[cursor];
255
+ if (final !== "M" && final !== "m") {
256
+ return null;
257
+ }
258
+ const [rawCode, rawColumn, rawRow] = parameters.split(";");
259
+ const code = Number.parseInt(rawCode, 10);
260
+ const column = Number.parseInt(rawColumn, 10);
261
+ const row = Number.parseInt(rawRow, 10);
262
+ if (!Number.isFinite(code) || !Number.isFinite(column) || !Number.isFinite(row)) {
263
+ return null;
264
+ }
265
+
266
+ const raw = input.slice(start, cursor + 1);
267
+ const modifiers = {
268
+ shift: (code & SHIFT) !== 0,
269
+ meta: (code & META) !== 0,
270
+ ctrl: (code & CTRL) !== 0,
271
+ };
272
+ // A terminal counts from one, this renderer counts from zero, and the
273
+ // conversion happens exactly here so that no handler ever has to know the
274
+ // terminal had a different opinion.
275
+ const x = Math.max(0, column - 1);
276
+ const y = Math.max(0, row - 1);
277
+ const held = code & NO_BUTTON;
278
+
279
+ if ((code & WHEEL) !== 0) {
280
+ return {
281
+ event: mouseEvent({
282
+ type: "scroll",
283
+ button: null,
284
+ x,
285
+ y,
286
+ ...modifiers,
287
+ raw,
288
+ scroll: { direction: WHEEL_DIRECTIONS[held], delta: 1 },
289
+ }),
290
+ length: raw.length,
291
+ };
292
+ }
293
+
294
+ const button = held === NO_BUTTON ? null : held;
295
+ const type: MouseEventType =
296
+ final === "m" ? "up" : (code & MOTION) !== 0 ? (button == null ? "move" : "drag") : "down";
297
+
298
+ return {
299
+ event: mouseEvent({ type, button, x, y, ...modifiers, raw }),
300
+ length: raw.length,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * How long an old-style `ESC [ M` report is: three introducer bytes and one
306
+ * each for the button, the column and the row.
307
+ *
308
+ * Named rather than written twice, because `keys.js` needs the same number to
309
+ * know when one of these has not all arrived yet.
310
+ */
311
+ export const LEGACY_REPORT_LENGTH: number = 6;
312
+
313
+ /**
314
+ * How many bytes an old-style `ESC [ M` report occupies, or zero.
315
+ *
316
+ * {@link LEGACY_REPORT_LENGTH} of them. The caller consumes them and emits
317
+ * nothing, which is the documented behaviour of this package on a terminal
318
+ * that does not implement SGR mouse reporting — see this module's header for
319
+ * why that is better than decoding them.
320
+ *
321
+ * Returns zero when fewer than that have arrived, so that a report split
322
+ * across two reads is not half-consumed. A decoder reading a stream holds
323
+ * those bytes until the rest of them arrive (`keys.js`, `incomplete`); the
324
+ * pure `decodeInput` has no later chunk to wait for and delivers three payload
325
+ * bytes as keys, which is the one case where this still leaks and needs a
326
+ * terminal without SGR *and* a caller that is not buffering.
327
+ */
328
+ export function legacyReportLength(input: string, start: number): number {
329
+ if (input[start + 2] !== "M") {
330
+ return 0;
331
+ }
332
+ return input.length - start >= LEGACY_REPORT_LENGTH ? LEGACY_REPORT_LENGTH : 0;
333
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@uniflowed/tui",
3
+ "version": "0.0.0-alpha.18",
4
+ "description": "A React renderer whose host is a terminal, following OpenTUI, for the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/tui"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./capability": "./capability.js",
16
+ "./cells": "./cells.js",
17
+ "./components": "./components.js",
18
+ "./diff": "./diff.js",
19
+ "./keys": "./keys.js",
20
+ "./layout": "./layout.js",
21
+ "./mouse": "./mouse.js",
22
+ "./selection": "./selection.js",
23
+ "./terminal": "./terminal.js",
24
+ "./widths": "./widths.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "*.js",
29
+ "internal/*.js",
30
+ "!*.test.js"
31
+ ],
32
+ "dependencies": {
33
+ "@uniflowed/react": "0.0.0-alpha.18",
34
+ "react-reconciler": "^0.33.0"
35
+ }
36
+ }
package/selection.js ADDED
@@ -0,0 +1,101 @@
1
+ // @flow
2
+ //
3
+ // What a drag over the frame selected: two cells, and the order between them.
4
+ //
5
+ // A selection here is geometry and nothing else. It is not a range of
6
+ // characters inside a string, and it is not a pair of nodes with offsets into
7
+ // them — it is the cell the pointer went down on and the cell it has reached,
8
+ // in the coordinates every other part of this package already uses.
9
+ //
10
+ // # Why two points on the screen rather than an offset into the text
11
+ //
12
+ // The obvious model is the DOM's: a start node and an offset, an end node and
13
+ // an offset. It is the wrong one here for the same reason the hit test is a
14
+ // grid rather than a walk over the tree. What a reader is selecting is what
15
+ // they can *see*, and what they can see is the frame: a row scrolled out of a
16
+ // `ScrollBox` has last frame's geometry on it, `overflow: "hidden"` cut a
17
+ // child off somewhere its geometry does not admit to, and a box painted later
18
+ // erased the text under it. Two screen cells are true about the picture in
19
+ // front of the reader; a node and an offset are true about a tree that
20
+ // disagrees with it.
21
+ //
22
+ // It is also what a terminal's own selection is, which matters more than it
23
+ // looks: a reader who drags across this renderer's output and a reader who
24
+ // drags across `cat`'s output are performing the same gesture, and the second
25
+ // one has taught them what to expect from the first.
26
+ //
27
+ // The cost of the model is stated rather than hidden. A selection survives a
28
+ // re-render, because two cells are still two cells; if the content under them
29
+ // moved, the selection now covers whatever moved into those cells. That is
30
+ // again what a terminal does, and the alternative — dropping the selection on
31
+ // every commit — would drop it on the keystroke that scrolled the window a
32
+ // reader was selecting from.
33
+ //
34
+ // # Reading order, not a rectangle
35
+ //
36
+ // `start` and `end` are the two points sorted by row and then by column, which
37
+ // is the order text is read in and the order OpenTUI documents
38
+ // `getSelectedText()` as joining in: top to bottom, left to right. A selection
39
+ // from the middle of one line to the middle of the line below it therefore
40
+ // covers the end of the first line, and not a rectangle standing on the two
41
+ // columns. Column selection is a different gesture and this is not it.
42
+
43
+ /** One cell of the frame, counted from zero at the top left. */
44
+ export type SelectionPoint = {
45
+ readonly x: number,
46
+ readonly y: number,
47
+ };
48
+
49
+ /**
50
+ * One selection, as the renderer holds it.
51
+ *
52
+ * There is at most one per renderer, which is OpenTUI's rule and a terminal's:
53
+ * a second selection would have to be shown, and the frame has one way of
54
+ * showing a cell is selected.
55
+ *
56
+ * `anchor` and `focus` are the gesture — where the press landed and where the
57
+ * pointer has reached — and are kept because that is what an application
58
+ * asking "which way is this drag going" needs. `start` and `end` are the same
59
+ * two points in reading order, which is what everything that walks the
60
+ * selection needs, and they are stored rather than recomputed so that no two
61
+ * readers of one selection can sort it differently.
62
+ */
63
+ export type Selection = {
64
+ /** The cell the press that began this selection landed on. */
65
+ readonly anchor: SelectionPoint,
66
+ /** The cell the pointer has reached. Moves as the drag continues. */
67
+ readonly focus: SelectionPoint,
68
+ /** The earlier of the two, by row and then by column. */
69
+ readonly start: SelectionPoint,
70
+ /** The later of the two. Both ends are *inside* the selection. */
71
+ readonly end: SelectionPoint,
72
+ };
73
+
74
+ /** Whether `a` is at or before `b` in reading order. */
75
+ function atOrBefore(a: SelectionPoint, b: SelectionPoint): boolean {
76
+ return a.y < b.y || (a.y === b.y && a.x <= b.x);
77
+ }
78
+
79
+ /**
80
+ * The selection a drag from `anchor` to `focus` describes.
81
+ *
82
+ * Both ends are inclusive: a press and a release on the same cell select that
83
+ * one cell rather than nothing. A reader who drags across a single character
84
+ * expects to have selected it, and a half-open range would make the shortest
85
+ * possible selection the empty one.
86
+ */
87
+ export function selectionBetween(anchor: SelectionPoint, focus: SelectionPoint): Selection {
88
+ const forwards = atOrBefore(anchor, focus);
89
+ return {
90
+ anchor,
91
+ focus,
92
+ start: forwards ? anchor : focus,
93
+ end: forwards ? focus : anchor,
94
+ };
95
+ }
96
+
97
+ /** Whether a cell is inside a selection, in reading order. */
98
+ export function selectionContains(selection: Selection, x: number, y: number): boolean {
99
+ const point = { x, y };
100
+ return atOrBefore(selection.start, point) && atOrBefore(point, selection.end);
101
+ }