@textui/core 0.1.0 → 0.3.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 +17 -20
- package/dist/app/app.d.ts +64 -0
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +202 -1
- package/dist/core/focus.d.ts.map +1 -1
- package/dist/core/focus.js +12 -1
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +2 -10
- package/dist/jsx/intrinsics.d.ts +18 -0
- package/dist/jsx/intrinsics.d.ts.map +1 -1
- package/dist/render/layout.js +10 -2
- package/dist/runtime/bindings.d.ts.map +1 -1
- package/dist/runtime/bindings.js +31 -4
- package/dist/runtime/hooks.d.ts +14 -2
- package/dist/runtime/hooks.d.ts.map +1 -1
- package/dist/runtime/hooks.js +15 -4
- package/dist/themes/builtin.d.ts +2 -0
- package/dist/themes/builtin.d.ts.map +1 -1
- package/dist/themes/builtin.js +64 -1
- package/dist/themes/dividers.d.ts +14 -0
- package/dist/themes/dividers.d.ts.map +1 -0
- package/dist/themes/dividers.js +42 -0
- package/dist/themes/glyphs.d.ts.map +1 -1
- package/dist/themes/glyphs.js +4 -0
- package/dist/themes/index.d.ts +1 -0
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +1 -0
- package/dist/themes/registry.d.ts.map +1 -1
- package/dist/themes/registry.js +31 -1
- package/dist/types/app.d.ts +15 -0
- package/dist/types/app.d.ts.map +1 -1
- package/dist/types/command.d.ts +18 -1
- package/dist/types/command.d.ts.map +1 -1
- package/dist/types/input.d.ts +9 -0
- package/dist/types/input.d.ts.map +1 -1
- package/dist/types/markdown.d.ts +36 -1
- package/dist/types/markdown.d.ts.map +1 -1
- package/dist/types/style.d.ts +28 -1
- package/dist/types/style.d.ts.map +1 -1
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/terminal.d.ts.map +1 -1
- package/dist/types/theme.d.ts +26 -1
- package/dist/types/theme.d.ts.map +1 -1
- package/dist/util/markdown.d.ts.map +1 -1
- package/dist/util/markdown.js +172 -2
- package/dist/util/paths.d.ts +9 -3
- package/dist/util/paths.d.ts.map +1 -1
- package/dist/util/paths.js +11 -16
- package/package.json +5 -5
- package/src/app/app.ts +198 -1
- package/src/core/focus.ts +12 -1
- package/src/core/store.ts +2 -7
- package/src/jsx/intrinsics.ts +18 -0
- package/src/render/layout.ts +7 -2
- package/src/runtime/bindings.ts +29 -3
- package/src/runtime/hooks.ts +15 -4
- package/src/themes/builtin.ts +65 -1
- package/src/themes/dividers.ts +48 -0
- package/src/themes/glyphs.ts +4 -0
- package/src/themes/index.ts +1 -0
- package/src/themes/registry.ts +30 -2
- package/src/types/app.ts +13 -0
- package/src/types/command.ts +18 -1
- package/src/types/input.ts +9 -0
- package/src/types/markdown.ts +38 -2
- package/src/types/style.ts +33 -1
- package/src/types/terminal.ts +9 -0
- package/src/types/theme.ts +29 -1
- package/src/util/markdown.ts +202 -3
- package/src/util/paths.ts +12 -15
package/src/app/app.ts
CHANGED
|
@@ -99,6 +99,27 @@ export class App implements TextUIApp {
|
|
|
99
99
|
private resolvedTheme: ResolvedTheme;
|
|
100
100
|
private bag = createBag();
|
|
101
101
|
private hovered: string | null = null;
|
|
102
|
+
/**
|
|
103
|
+
* Whoever took the last button down, until it comes back up.
|
|
104
|
+
*
|
|
105
|
+
* Mouse dispatch is a hit test, so without this a drag stops the moment the
|
|
106
|
+
* pointer leaves the box it started in - which is precisely when a drag
|
|
107
|
+
* becomes interesting. A selection dragged off the bottom of a field, a
|
|
108
|
+
* slider dragged past its own track and a splitter dragged across the pane
|
|
109
|
+
* it is resizing are all the pointer being somewhere the handler is not.
|
|
110
|
+
*/
|
|
111
|
+
private captured: Instance | null = null;
|
|
112
|
+
/**
|
|
113
|
+
* Everything under the pointer, innermost outwards.
|
|
114
|
+
*
|
|
115
|
+
* A chain rather than one node, because hover is inherited the way it is in
|
|
116
|
+
* a browser: a row lights up when the pointer is over the label inside it,
|
|
117
|
+
* and the label is what the hit test finds. Hover used to be a focus id
|
|
118
|
+
* compared against `props.id`, which meant only a focusable node could ever
|
|
119
|
+
* be hovered - so a tool call row, a list row and every other box that is
|
|
120
|
+
* clicked rather than focused had a `hover` style nothing could trigger.
|
|
121
|
+
*/
|
|
122
|
+
private hoveredChain = new Set<Instance>();
|
|
102
123
|
/** Focus registrations created from `focusable` props, by focus id. */
|
|
103
124
|
private declaredFocus = new Map<string, { instanceId: string; dispose(): void }>();
|
|
104
125
|
private lastFrame: Frame | null = null;
|
|
@@ -258,11 +279,23 @@ export class App implements TextUIApp {
|
|
|
258
279
|
}
|
|
259
280
|
this.themeId = id;
|
|
260
281
|
this.resolvedTheme = this.themes.resolve(id, this.terminal.capabilities());
|
|
282
|
+
this.applyCursorShape();
|
|
261
283
|
this.store.set('$/modus/theme', id);
|
|
262
284
|
this.buffer_.invalidate();
|
|
263
285
|
this.requestRender(true);
|
|
264
286
|
}
|
|
265
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Ask the terminal for the theme's caret, when the theme states one.
|
|
290
|
+
*
|
|
291
|
+
* A theme that says nothing leaves the terminal's own setting alone, which
|
|
292
|
+
* is why this is not called with a default.
|
|
293
|
+
*/
|
|
294
|
+
private applyCursorShape(): void {
|
|
295
|
+
const shape = this.resolvedTheme.cursor;
|
|
296
|
+
if (shape) this.terminal.setCursorShape?.(shape);
|
|
297
|
+
}
|
|
298
|
+
|
|
266
299
|
setShell(id: string): void {
|
|
267
300
|
if (!this.shells.get(id)) {
|
|
268
301
|
throw new Error(`[textui] no shell registered as "${id}"`);
|
|
@@ -271,6 +304,22 @@ export class App implements TextUIApp {
|
|
|
271
304
|
this.store.set('$/layout/shell', id);
|
|
272
305
|
const shell = this.shells.get(id);
|
|
273
306
|
if (shell?.theme && this.themes.get(shell.theme)) this.setTheme(shell.theme);
|
|
307
|
+
|
|
308
|
+
// `root` reaches the screen two ways, and this is the moment it changes
|
|
309
|
+
// which. With no shell registered at boot it was never opened into `main` -
|
|
310
|
+
// `rootNode` wraps it directly, which is the path an application built out
|
|
311
|
+
// of primitives takes. A shell arriving afterwards makes `rootNode` return
|
|
312
|
+
// the shell instead, and the application's entire content was simply gone:
|
|
313
|
+
// a framed, themed, empty screen.
|
|
314
|
+
//
|
|
315
|
+
// `setRoot` has always handled both paths, and says why - "setting one and
|
|
316
|
+
// not the other works in exactly half of the programs that can exist". This
|
|
317
|
+
// is the other half of the same sentence. Opening the same key twice
|
|
318
|
+
// replaces the mount rather than stacking one, so no guard is needed.
|
|
319
|
+
if (this.options.root) {
|
|
320
|
+
this.surfaces.open({ surface: 'main', key: ROOT_KEY, target: this.options.root });
|
|
321
|
+
}
|
|
322
|
+
|
|
274
323
|
this.buffer_.invalidate();
|
|
275
324
|
this.requestRender(true);
|
|
276
325
|
}
|
|
@@ -282,6 +331,7 @@ export class App implements TextUIApp {
|
|
|
282
331
|
setCapabilityOverrides(overrides: CapabilityOverrides): void {
|
|
283
332
|
this.terminal.setCapabilityOverrides(overrides);
|
|
284
333
|
this.resolvedTheme = this.themes.resolve(this.themeId, this.terminal.capabilities());
|
|
334
|
+
this.applyCursorShape();
|
|
285
335
|
this.publishEnvironment();
|
|
286
336
|
this.buffer_.invalidate();
|
|
287
337
|
this.requestRender(true);
|
|
@@ -325,6 +375,10 @@ export class App implements TextUIApp {
|
|
|
325
375
|
...this.options.session,
|
|
326
376
|
});
|
|
327
377
|
|
|
378
|
+
// After `acquire`, never before: with no session there is nothing to put
|
|
379
|
+
// the shape back, so an early call is dropped rather than leaked.
|
|
380
|
+
this.applyCursorShape();
|
|
381
|
+
|
|
328
382
|
this.bag.add(this.terminal.onInput((event) => this.handleInput(event)));
|
|
329
383
|
this.bag.add(this.terminal.onResize((size) => this.handleResize(size)));
|
|
330
384
|
|
|
@@ -428,6 +482,44 @@ export class App implements TextUIApp {
|
|
|
428
482
|
this.renderFrame();
|
|
429
483
|
}
|
|
430
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Render until there is nothing left to render.
|
|
487
|
+
*
|
|
488
|
+
* `flush` forces one frame; this is the other question - *has it finished?* -
|
|
489
|
+
* and there was no way to ask it. What a program wanting one true frame did
|
|
490
|
+
* instead was guess: every example that writes a still ended with a sleep
|
|
491
|
+
* loop of four milliseconds times a number somebody tried until the picture
|
|
492
|
+
* looked right. Eight, mostly. Four in one, twelve in another. A number too
|
|
493
|
+
* small does not fail; it writes a half-drawn frame.
|
|
494
|
+
*
|
|
495
|
+
* A frame settles in more than one pass by design - an effect may mark
|
|
496
|
+
* something dirty, and a measurement changing runs the layout again - so the
|
|
497
|
+
* answer is a loop rather than a flag. Each turn yields to the task queue
|
|
498
|
+
* first, because what has not run yet cannot have marked anything.
|
|
499
|
+
*
|
|
500
|
+
* It returns as soon as a pass finds nothing pending, so an application that
|
|
501
|
+
* animates settles *between* its frames - which is what makes a still of one
|
|
502
|
+
* possible at all, and why the answer is not "has it stopped moving".
|
|
503
|
+
*
|
|
504
|
+
* `false` is the other thing: passes that kept producing work until the
|
|
505
|
+
* limit ran out, which is a render loop that does not converge - an effect
|
|
506
|
+
* with no dependency list setting the state it reads. Worth reporting rather
|
|
507
|
+
* than hanging on, and worth a number rather than a promise a caller can
|
|
508
|
+
* wait on for ever.
|
|
509
|
+
*/
|
|
510
|
+
async settled(options: { limit?: number } = {}): Promise<boolean> {
|
|
511
|
+
const limit = options.limit ?? 100;
|
|
512
|
+
for (let i = 0; i < limit; i++) {
|
|
513
|
+
// Deliberately not `unref`'d: this timer is what keeps the process alive
|
|
514
|
+
// while a still is being rendered, and one that lets it exit would leave
|
|
515
|
+
// the await unresolved and the frame unwritten.
|
|
516
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
517
|
+
if (!this.frameScheduled && !this.isDirty()) return true;
|
|
518
|
+
this.flush();
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
431
523
|
/**
|
|
432
524
|
* The tree the frame renders: the shell, always, when one is registered.
|
|
433
525
|
*
|
|
@@ -625,7 +717,7 @@ export class App implements TextUIApp {
|
|
|
625
717
|
const focusedId = this.focus.focused();
|
|
626
718
|
return {
|
|
627
719
|
focused: focusedId === id || focusedId === `${instance.id}:focus`,
|
|
628
|
-
hovered: this.hovered === id,
|
|
720
|
+
hovered: this.hovered === id || this.hoveredChain.has(instance),
|
|
629
721
|
active: false,
|
|
630
722
|
selected: instance.props.selected === true,
|
|
631
723
|
disabled: instance.props.disabled === true,
|
|
@@ -907,6 +999,28 @@ export class App implements TextUIApp {
|
|
|
907
999
|
return;
|
|
908
1000
|
}
|
|
909
1001
|
|
|
1002
|
+
/*
|
|
1003
|
+
* A modal takes escape before any keybinding does.
|
|
1004
|
+
*
|
|
1005
|
+
* `dismissOnEscape` was unreachable in any application with a global
|
|
1006
|
+
* escape binding, which most have: the binding matched first, the layer
|
|
1007
|
+
* stayed open, and the key went to whatever "go back" means behind it.
|
|
1008
|
+
* What that looks like is a confirm dialog you cannot leave, over a screen
|
|
1009
|
+
* that has navigated somewhere else while you were reading it.
|
|
1010
|
+
*
|
|
1011
|
+
* The test is `trapFocus`, not `dismissOnEscape` alone. A layer that traps
|
|
1012
|
+
* focus has claimed the keyboard, so nothing behind it should be acting on
|
|
1013
|
+
* keys; a toast or a tooltip has not, and those keep the old order so that
|
|
1014
|
+
* escape still reaches the application while one happens to be up.
|
|
1015
|
+
*/
|
|
1016
|
+
if (event.name === 'escape') {
|
|
1017
|
+
const top = this.layers.topmostDismissible();
|
|
1018
|
+
if (top && top.trapFocus) {
|
|
1019
|
+
this.layers.close(top.id, 'escape');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
910
1024
|
if (this.keybindings.handle(event) !== 'unhandled') {
|
|
911
1025
|
this.requestRender();
|
|
912
1026
|
return;
|
|
@@ -930,16 +1044,44 @@ export class App implements TextUIApp {
|
|
|
930
1044
|
}
|
|
931
1045
|
|
|
932
1046
|
private handleMouse(event: MouseEvent): void {
|
|
1047
|
+
// The rest of the gesture belongs to whoever took the button down,
|
|
1048
|
+
// wherever the pointer has got to since - and it is delivered whether or
|
|
1049
|
+
// not the handler claims it, because during a drag there is nothing else
|
|
1050
|
+
// it could be for.
|
|
1051
|
+
if (this.captured && (event.action === 'drag' || event.action === 'up')) {
|
|
1052
|
+
const target = this.captured;
|
|
1053
|
+
if (event.action === 'up') this.captured = null;
|
|
1054
|
+
if (target.mounted) {
|
|
1055
|
+
const onMouse = target.props.onMouse;
|
|
1056
|
+
if (typeof onMouse === 'function') {
|
|
1057
|
+
(onMouse as (e: MouseEvent) => boolean | void)(event);
|
|
1058
|
+
this.requestRender();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
// Unmounted mid-drag, or it stopped taking the pointer: the gesture has
|
|
1063
|
+
// nowhere to go, so let the hit test have it back.
|
|
1064
|
+
this.captured = null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
933
1067
|
const hit = this.focus.at(event.x, event.y);
|
|
934
1068
|
|
|
935
1069
|
if (event.action === 'move') {
|
|
1070
|
+
const moved = this.updateHover(event);
|
|
936
1071
|
if (hit !== this.hovered) {
|
|
937
1072
|
this.hovered = hit;
|
|
938
1073
|
this.requestRender();
|
|
1074
|
+
} else if (moved) {
|
|
1075
|
+
this.requestRender();
|
|
939
1076
|
}
|
|
940
1077
|
return;
|
|
941
1078
|
}
|
|
942
1079
|
|
|
1080
|
+
// A new press ends the last one, claimed or not: an `up` that never
|
|
1081
|
+
// arrived - a terminal that lost focus mid-drag - must not leave a capture
|
|
1082
|
+
// that eats the next gesture.
|
|
1083
|
+
if (event.action === 'down') this.captured = null;
|
|
1084
|
+
|
|
943
1085
|
if (event.action === 'down' && hit) this.focus.focus(hit);
|
|
944
1086
|
|
|
945
1087
|
if (this.root) {
|
|
@@ -948,6 +1090,41 @@ export class App implements TextUIApp {
|
|
|
948
1090
|
this.requestRender();
|
|
949
1091
|
}
|
|
950
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* Who the pointer is over now, and who it has just left.
|
|
1095
|
+
*
|
|
1096
|
+
* `onHover` is called on the way in and the way out, once each - a handler
|
|
1097
|
+
* that fires on every pixel of movement is a handler nobody can use for
|
|
1098
|
+
* anything but a repaint.
|
|
1099
|
+
*/
|
|
1100
|
+
private updateHover(event: MouseEvent): boolean {
|
|
1101
|
+
const next = new Set<Instance>();
|
|
1102
|
+
if (this.root) collectHover(this.root, event.x, event.y, next);
|
|
1103
|
+
|
|
1104
|
+
let changed = next.size !== this.hoveredChain.size;
|
|
1105
|
+
if (!changed) {
|
|
1106
|
+
for (const instance of next) {
|
|
1107
|
+
if (!this.hoveredChain.has(instance)) { changed = true; break; }
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (!changed) return false;
|
|
1111
|
+
|
|
1112
|
+
const previous = this.hoveredChain;
|
|
1113
|
+
this.hoveredChain = next;
|
|
1114
|
+
|
|
1115
|
+
for (const instance of previous) {
|
|
1116
|
+
if (next.has(instance) || !instance.mounted) continue;
|
|
1117
|
+
const onHover = instance.props.onHover;
|
|
1118
|
+
if (typeof onHover === 'function') (onHover as (over: boolean) => void)(false);
|
|
1119
|
+
}
|
|
1120
|
+
for (const instance of next) {
|
|
1121
|
+
if (previous.has(instance)) continue;
|
|
1122
|
+
const onHover = instance.props.onHover;
|
|
1123
|
+
if (typeof onHover === 'function') (onHover as (over: boolean) => void)(true);
|
|
1124
|
+
}
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
951
1128
|
/** Innermost box under the pointer first, then outward. */
|
|
952
1129
|
private dispatchMouse(instance: Instance, event: MouseEvent): boolean {
|
|
953
1130
|
for (let i = instance.children.length - 1; i >= 0; i--) {
|
|
@@ -962,6 +1139,8 @@ export class App implements TextUIApp {
|
|
|
962
1139
|
|
|
963
1140
|
const onMouse = instance.props.onMouse;
|
|
964
1141
|
if (typeof onMouse === 'function' && (onMouse as (e: MouseEvent) => boolean | void)(event) === true) {
|
|
1142
|
+
// Taking the button down is what claims the drag that follows it.
|
|
1143
|
+
if (event.action === 'down') this.captured = instance;
|
|
965
1144
|
return true;
|
|
966
1145
|
}
|
|
967
1146
|
|
|
@@ -1094,3 +1273,21 @@ export const WRITER_KEY = serviceKey<FrameWriter>('textui.writer');
|
|
|
1094
1273
|
export function createApp(options: CreateAppOptions = {}): App {
|
|
1095
1274
|
return new App(options);
|
|
1096
1275
|
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Every box containing the point, from the root down.
|
|
1279
|
+
*
|
|
1280
|
+
* The whole chain rather than the innermost one: a row is hovered when the
|
|
1281
|
+
* pointer is over the text inside it, and the text is what a hit test finds.
|
|
1282
|
+
* Only boxes with a laid-out rect count - a component that produced no box
|
|
1283
|
+
* occupies no space, so there is nothing to be over.
|
|
1284
|
+
*/
|
|
1285
|
+
function collectHover(instance: Instance, x: number, y: number, into: Set<Instance>): void {
|
|
1286
|
+
const box = instance.box;
|
|
1287
|
+
if (box) {
|
|
1288
|
+
const { x: bx, y: by, width, height } = box.rect;
|
|
1289
|
+
if (x < bx || x >= bx + width || y < by || y >= by + height) return;
|
|
1290
|
+
into.add(instance);
|
|
1291
|
+
}
|
|
1292
|
+
for (const child of instance.children) collectHover(child, x, y, into);
|
|
1293
|
+
}
|
package/src/core/focus.ts
CHANGED
|
@@ -129,7 +129,18 @@ export class Focus implements FocusManager {
|
|
|
129
129
|
// the screen this is for - pushed over whatever had focus, which the push
|
|
130
130
|
// unmounted, leaving none - and a dialog, which opens while its opener
|
|
131
131
|
// still holds focus and whose own controls say which of them wants it.
|
|
132
|
-
|
|
132
|
+
//
|
|
133
|
+
// And only onto something a person could have tabbed to. A `global`
|
|
134
|
+
// handler registers as a focusable - that is how a layer reads escape
|
|
135
|
+
// without holding focus - and it is `skipTab`, so it is not a place focus
|
|
136
|
+
// can land. Handed it anyway, focus sat on a node that consumes nothing:
|
|
137
|
+
// the keys fell through to whatever was behind, and every real control's
|
|
138
|
+
// own `autoFocus` stood down, because it claims focus only when the scope
|
|
139
|
+
// does not already hold it. A question with a text field in it was
|
|
140
|
+
// therefore unanswerable - the field was drawn, `required` beside it, and
|
|
141
|
+
// what was typed at it went into the composer underneath.
|
|
142
|
+
const focusable = options.disabled !== true && options.skipTab !== true && options.global !== true;
|
|
143
|
+
if (!existing && focusable && this.current === null) {
|
|
133
144
|
const scopeId = options.scopeId ?? GLOBAL_SCOPE;
|
|
134
145
|
if (this.scopes.get(scopeId)?.autoFocus === true && this.stack.includes(scopeId)) {
|
|
135
146
|
this.focus(options.id);
|
package/src/core/store.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { BindingPath } from '../types/graph.js';
|
|
|
6
6
|
import type { Disposable } from '../types/disposable.js';
|
|
7
7
|
import { toDisposable } from '../util/disposable.js';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
hasWildcard, isDescendantKey, keysTouch, matchKey, pathKey, PathError,
|
|
10
10
|
} from '../util/paths.js';
|
|
11
11
|
|
|
12
12
|
interface Subscription {
|
|
@@ -222,12 +222,7 @@ export class Store implements ReactiveStore {
|
|
|
222
222
|
|
|
223
223
|
private subMatches(sub: Subscription, changedKey: string): boolean {
|
|
224
224
|
if (sub.wildcard) return matchKey(changedKey, sub.key, sub.subtree);
|
|
225
|
-
|
|
226
|
-
// A write below an exact subscription still changes that object's contents.
|
|
227
|
-
if (isDescendantKey(changedKey, sub.key)) return true;
|
|
228
|
-
// A write above it may have replaced the subtree the subscriber reads.
|
|
229
|
-
if (isDescendantKey(sub.key, changedKey)) return true;
|
|
230
|
-
return sub.subtree && ancestorKeys(changedKey).includes(sub.key);
|
|
225
|
+
return keysTouch(changedKey, sub.key);
|
|
231
226
|
}
|
|
232
227
|
|
|
233
228
|
// ---------------------------------------------------------- subscriptions
|
package/src/jsx/intrinsics.ts
CHANGED
|
@@ -42,8 +42,26 @@ export interface BaseProps extends Style {
|
|
|
42
42
|
onKey?(event: KeyEvent): boolean | void;
|
|
43
43
|
onFocus?(): void;
|
|
44
44
|
onBlur?(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Every mouse action on this node, innermost first.
|
|
47
|
+
*
|
|
48
|
+
* Returning `true` stops it going any further - and on a `down`, **claims
|
|
49
|
+
* the rest of the gesture**: the `drag`s and the `up` that follow come here
|
|
50
|
+
* whatever they are over, until the button comes back up. Dispatch is
|
|
51
|
+
* otherwise a hit test, so without that a drag would stop at the edge of the
|
|
52
|
+
* node it started in, which is where a drag starts being worth having.
|
|
53
|
+
*/
|
|
45
54
|
onMouse?(event: MouseEvent): boolean | void;
|
|
55
|
+
/** The left button going down - a third of a gesture. `onMouse` for the rest. */
|
|
46
56
|
onClick?: Action | ((event: MouseEvent) => void);
|
|
57
|
+
/**
|
|
58
|
+
* The pointer entered or left this node. Called once each way, not per cell.
|
|
59
|
+
*
|
|
60
|
+
* Hover is inherited the way it is in a browser: a row is hovered while the
|
|
61
|
+
* pointer is over the label inside it, because the label is what a hit test
|
|
62
|
+
* finds. A `style` with a `hover` overlay needs nothing else - this is for
|
|
63
|
+
* the cases where something other than a colour has to happen.
|
|
64
|
+
*/
|
|
47
65
|
onHover?(hovering: boolean): void;
|
|
48
66
|
|
|
49
67
|
/** OSC 8 link target, where the terminal supports hyperlinks. */
|
package/src/render/layout.ts
CHANGED
|
@@ -465,8 +465,13 @@ function layoutBox(box: LayoutBox, rect: Rect): void {
|
|
|
465
465
|
return;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
-
const flow =
|
|
469
|
-
const absolute =
|
|
468
|
+
const flow: LayoutBox[] = [];
|
|
469
|
+
const absolute: LayoutBox[] = [];
|
|
470
|
+
for (const child of box.children) {
|
|
471
|
+
if (isHidden(child)) continue;
|
|
472
|
+
if (isAbsolute(child)) absolute.push(child);
|
|
473
|
+
else flow.push(child);
|
|
474
|
+
}
|
|
470
475
|
|
|
471
476
|
if (flow.length > 0) layoutFlow(box, flow);
|
|
472
477
|
for (const child of absolute) layoutAbsolute(box, child);
|
package/src/runtime/bindings.ts
CHANGED
|
@@ -94,13 +94,39 @@ export function resolveValue(ctx: ResolveContext, value: unknown): unknown {
|
|
|
94
94
|
if (isComponentNode(value)) return value;
|
|
95
95
|
if (isAction(value)) return value;
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
/*
|
|
98
|
+
* The same array back when there was nothing in it to resolve.
|
|
99
|
+
*
|
|
100
|
+
* Identity is what the reconciler compares - a component whose props are
|
|
101
|
+
* all unchanged is not re-run, and neither is anything under it - so
|
|
102
|
+
* copying unconditionally made that test impossible to pass for exactly
|
|
103
|
+
* the props worth passing it for. A list of four hundred rows arrived as a
|
|
104
|
+
* new array on every pass, its holder re-rendered every frame whatever it
|
|
105
|
+
* had been told, and the memoisation callers wrote to prevent that could
|
|
106
|
+
* not reach this far.
|
|
107
|
+
*
|
|
108
|
+
* Copying at all is for the bindings: a `{ path }` inside an array has to
|
|
109
|
+
* become the value it names, and that is a different array. So the copy is
|
|
110
|
+
* kept and returned only when something in it actually changed.
|
|
111
|
+
*/
|
|
112
|
+
if (Array.isArray(value)) {
|
|
113
|
+
let changed = false;
|
|
114
|
+
const items = value.map((v) => {
|
|
115
|
+
const resolved = resolveValue(ctx, v);
|
|
116
|
+
if (resolved !== v) changed = true;
|
|
117
|
+
return resolved;
|
|
118
|
+
});
|
|
119
|
+
return changed ? items : value;
|
|
120
|
+
}
|
|
98
121
|
|
|
122
|
+
let changed = false;
|
|
99
123
|
const out: Record<string, unknown> = {};
|
|
100
124
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
101
|
-
|
|
125
|
+
const resolved = resolveValue(ctx, v);
|
|
126
|
+
if (resolved !== v) changed = true;
|
|
127
|
+
out[k] = resolved;
|
|
102
128
|
}
|
|
103
|
-
return out;
|
|
129
|
+
return changed ? out : value;
|
|
104
130
|
}
|
|
105
131
|
|
|
106
132
|
/**
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -1001,14 +1001,25 @@ export function useTicker(
|
|
|
1001
1001
|
}, [enabled, options.fps]);
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
-
/**
|
|
1005
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* A frame counter, for spinners and marquees. Frozen when animation is off.
|
|
1006
|
+
*
|
|
1007
|
+
* `enabled` is not a convenience. A ticker is a standing invalidation - it
|
|
1008
|
+
* marks its component dirty `fps` times a second for as long as it is
|
|
1009
|
+
* mounted - so a component that animates only sometimes and calls this
|
|
1010
|
+
* unconditionally keeps the whole application rendering while it sits still.
|
|
1011
|
+
* That is invisible in a small tree and is the entire frame budget in a large
|
|
1012
|
+
* one, which is the case nobody tests. Frozen at 0 while off, so a caret or a
|
|
1013
|
+
* spinner drawn from it is steady rather than absent.
|
|
1014
|
+
*/
|
|
1015
|
+
export function useFrame(fps = 10, options: { enabled?: boolean } = {}): number {
|
|
1006
1016
|
const instance = currentInstance();
|
|
1007
1017
|
const [frame, setFrame] = useState(0);
|
|
1008
1018
|
const disabled = instance.runtime.animation.disabled;
|
|
1019
|
+
const running = (options.enabled ?? true) && !disabled;
|
|
1009
1020
|
|
|
1010
|
-
useTicker(() => setFrame((f) => f + 1), { fps, enabled:
|
|
1011
|
-
return
|
|
1021
|
+
useTicker(() => setFrame((f) => f + 1), { fps, enabled: running });
|
|
1022
|
+
return running ? frame : 0;
|
|
1012
1023
|
}
|
|
1013
1024
|
|
|
1014
1025
|
/** A value that eases towards its target. Snaps when animation is off. */
|
package/src/themes/builtin.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const DARK: ThemeDefinition = {
|
|
|
24
24
|
border: '#30363d',
|
|
25
25
|
borderStrong: '#484f58',
|
|
26
26
|
borderSubtle: '#21262d',
|
|
27
|
+
divider: '#484f58',
|
|
27
28
|
text: '#e6edf3',
|
|
28
29
|
muted: '#8b949e',
|
|
29
30
|
subtle: '#6e7681',
|
|
@@ -70,6 +71,7 @@ export const LIGHT: ThemeDefinition = {
|
|
|
70
71
|
border: '#d0d7de',
|
|
71
72
|
borderStrong: '#8c959f',
|
|
72
73
|
borderSubtle: '#eaeef2',
|
|
74
|
+
divider: '#8c959f',
|
|
73
75
|
text: '#1f2328',
|
|
74
76
|
muted: '#656d76',
|
|
75
77
|
subtle: '#8c959f',
|
|
@@ -115,6 +117,7 @@ export const CONSOLE: ThemeDefinition = {
|
|
|
115
117
|
surface: '#0a0e14',
|
|
116
118
|
border: '#3b4252',
|
|
117
119
|
borderStrong: '#5e81ac',
|
|
120
|
+
divider: '#5e81ac',
|
|
118
121
|
accent: '#88c0d0',
|
|
119
122
|
text: '#d8dee9',
|
|
120
123
|
muted: '#616e88',
|
|
@@ -145,6 +148,59 @@ export const CONSOLE: ThemeDefinition = {
|
|
|
145
148
|
export const PAPER: ThemeDefinition = {
|
|
146
149
|
id: 'paper',
|
|
147
150
|
name: 'Paper',
|
|
151
|
+
appearance: 'dark',
|
|
152
|
+
extends: 'dark',
|
|
153
|
+
border: 'none',
|
|
154
|
+
density: 'airy',
|
|
155
|
+
colors: {
|
|
156
|
+
canvas: 'default',
|
|
157
|
+
surface: 'default',
|
|
158
|
+
surfaceAlt: 'default',
|
|
159
|
+
overlay: 'default',
|
|
160
|
+
border: 'default',
|
|
161
|
+
borderStrong: 'default',
|
|
162
|
+
borderSubtle: 'default',
|
|
163
|
+
divider: 'default',
|
|
164
|
+
text: 'default',
|
|
165
|
+
muted: '#9b949e',
|
|
166
|
+
subtle: '#a7a7a7',
|
|
167
|
+
inverted: '#0d1117',
|
|
168
|
+
accent: '#58a6ff',
|
|
169
|
+
primary: '#388bfd',
|
|
170
|
+
secondary: '#a371f7',
|
|
171
|
+
success: '#3fb950',
|
|
172
|
+
warning: '#d29922',
|
|
173
|
+
danger: '#f85149',
|
|
174
|
+
info: '#58a6ff',
|
|
175
|
+
onAccent: '#0d1117',
|
|
176
|
+
onDefault: '#0d1117',
|
|
177
|
+
onPrimary: '#0d1117',
|
|
178
|
+
onSecondary: '#0d1117',
|
|
179
|
+
onMuted: '#0d1117',
|
|
180
|
+
onSuccess: '#0d1117',
|
|
181
|
+
onInfo: '#0d1117',
|
|
182
|
+
onWarning: '#0d1117',
|
|
183
|
+
onDanger: '#0d1117',
|
|
184
|
+
hover: '#1f2937',
|
|
185
|
+
active: '#264466',
|
|
186
|
+
selected: '#1f6feb',
|
|
187
|
+
focus: '#58a6ff',
|
|
188
|
+
disabled: '#484f58',
|
|
189
|
+
scrim: '#010409',
|
|
190
|
+
cursor: 'default',
|
|
191
|
+
shadow: '#010409',
|
|
192
|
+
},
|
|
193
|
+
spacing: { none: 0, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 },
|
|
194
|
+
components: {
|
|
195
|
+
Panel: { base: { border: 'none', padding: [1, 2] } },
|
|
196
|
+
Button: { base: { padding: [0, 2] } },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Borderless. Whitespace and alignment do the separating. */
|
|
201
|
+
export const PAPER_LIGHT: ThemeDefinition = {
|
|
202
|
+
id: 'paper-light',
|
|
203
|
+
name: 'Paper Light',
|
|
148
204
|
appearance: 'light',
|
|
149
205
|
extends: 'light',
|
|
150
206
|
border: 'none',
|
|
@@ -155,6 +211,7 @@ export const PAPER: ThemeDefinition = {
|
|
|
155
211
|
surfaceAlt: '#f5f2ec',
|
|
156
212
|
border: '#e5e0d8',
|
|
157
213
|
borderSubtle: '#f0ece5',
|
|
214
|
+
divider: 'default',
|
|
158
215
|
subtle: '#c56532',
|
|
159
216
|
text: '#2b2a27',
|
|
160
217
|
muted: '#7a756c',
|
|
@@ -192,6 +249,7 @@ export const WORKBENCH: ThemeDefinition = {
|
|
|
192
249
|
overlay: '#181825',
|
|
193
250
|
border: '#45475a',
|
|
194
251
|
borderStrong: '#585b70',
|
|
252
|
+
divider: '#585b70',
|
|
195
253
|
text: '#cdd6f4',
|
|
196
254
|
muted: '#a6adc8',
|
|
197
255
|
subtle: '#6c7086',
|
|
@@ -219,6 +277,10 @@ export const MONO: ThemeDefinition = {
|
|
|
219
277
|
name: 'Monochrome',
|
|
220
278
|
appearance: 'dark',
|
|
221
279
|
border: 'ascii',
|
|
280
|
+
cursor: 'underline',
|
|
281
|
+
// Chosen, not downgraded to: this theme is ascii on a terminal that could
|
|
282
|
+
// draw anything, so the rule has to say so too.
|
|
283
|
+
divider: 'ascii',
|
|
222
284
|
density: 'normal',
|
|
223
285
|
colors: {
|
|
224
286
|
canvas: 'default',
|
|
@@ -228,6 +290,7 @@ export const MONO: ThemeDefinition = {
|
|
|
228
290
|
border: 'default',
|
|
229
291
|
borderStrong: 'default',
|
|
230
292
|
borderSubtle: 'default',
|
|
293
|
+
divider: 'default',
|
|
231
294
|
text: 'default',
|
|
232
295
|
muted: 'default',
|
|
233
296
|
subtle: 'default',
|
|
@@ -277,6 +340,7 @@ export const PAPER_DARK: ThemeDefinition = {
|
|
|
277
340
|
surfaceAlt: '#26231f',
|
|
278
341
|
border: '#3a352e',
|
|
279
342
|
borderSubtle: '#2a2621',
|
|
343
|
+
divider: 'default',
|
|
280
344
|
subtle: '#db8c4c',
|
|
281
345
|
text: '#e8e3d9',
|
|
282
346
|
muted: '#9a9287',
|
|
@@ -297,5 +361,5 @@ export const PAPER_DARK: ThemeDefinition = {
|
|
|
297
361
|
};
|
|
298
362
|
|
|
299
363
|
export const BUILTIN_THEMES: ThemeDefinition[] = [
|
|
300
|
-
DARK, LIGHT, CONSOLE, PAPER, PAPER_DARK, WORKBENCH, MONO,
|
|
364
|
+
DARK, LIGHT, CONSOLE, PAPER, PAPER_LIGHT, PAPER_DARK, WORKBENCH, MONO,
|
|
301
365
|
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DividerChars, DividerStyle } from '../types/style.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The divider sets.
|
|
5
|
+
*
|
|
6
|
+
* A parallel to `BORDER_SETS`, and deliberately not part of it. A border is
|
|
7
|
+
* thirteen characters that only mean anything together - they enclose a box.
|
|
8
|
+
* A divider encloses nothing: it is one rule, in one direction, whose whole
|
|
9
|
+
* job is to separate. Folding it into `BorderChars` made a theme choose a
|
|
10
|
+
* frame style in order to choose a rule, so a borderless theme could not have
|
|
11
|
+
* one without every bordered component reserving a ring it never draws.
|
|
12
|
+
*/
|
|
13
|
+
export const DIVIDER_SETS: Record<DividerStyle, DividerChars> = {
|
|
14
|
+
none: { horizontal: ' ', vertical: ' ' },
|
|
15
|
+
single: { horizontal: '┈', vertical: '│' },
|
|
16
|
+
double: { horizontal: '═', vertical: '║' },
|
|
17
|
+
dashed: { horizontal: '┄', vertical: '┆' },
|
|
18
|
+
thick: { horizontal: '━', vertical: '┃' },
|
|
19
|
+
ascii: { horizontal: '-', vertical: '|' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What a terminal with no box drawing gets instead. */
|
|
23
|
+
const ASCII_FALLBACK: Record<DividerStyle, DividerStyle> = {
|
|
24
|
+
none: 'none',
|
|
25
|
+
single: 'ascii',
|
|
26
|
+
double: 'ascii',
|
|
27
|
+
dashed: 'ascii',
|
|
28
|
+
thick: 'ascii',
|
|
29
|
+
ascii: 'ascii',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The BMP tier has box drawing but not every weight of it: `┄` and `━` are
|
|
34
|
+
* outside it, and a missing glyph is a question mark on somebody's terminal.
|
|
35
|
+
*/
|
|
36
|
+
const BMP_FALLBACK: Partial<Record<DividerStyle, DividerStyle>> = {
|
|
37
|
+
dashed: 'single',
|
|
38
|
+
thick: 'single',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function dividerCharsFor(
|
|
42
|
+
style: DividerStyle,
|
|
43
|
+
unicode: 'ascii' | 'bmp' | 'full',
|
|
44
|
+
): DividerChars {
|
|
45
|
+
if (unicode === 'ascii') return DIVIDER_SETS[ASCII_FALLBACK[style]];
|
|
46
|
+
if (unicode === 'bmp') return DIVIDER_SETS[BMP_FALLBACK[style] ?? style];
|
|
47
|
+
return DIVIDER_SETS[style];
|
|
48
|
+
}
|
package/src/themes/glyphs.ts
CHANGED
|
@@ -22,6 +22,8 @@ export const FULL_GLYPHS: ThemeGlyphs = {
|
|
|
22
22
|
chevronUp: '▴',
|
|
23
23
|
arrowUp: '↑',
|
|
24
24
|
arrowDown: '↓',
|
|
25
|
+
arrowLeft: '←',
|
|
26
|
+
arrowRight: '→',
|
|
25
27
|
ellipsis: '…',
|
|
26
28
|
search: '⌕',
|
|
27
29
|
radioOn: '●',
|
|
@@ -71,6 +73,8 @@ export const ASCII_GLYPHS: ThemeGlyphs = {
|
|
|
71
73
|
chevronUp: '^',
|
|
72
74
|
arrowUp: '^',
|
|
73
75
|
arrowDown: 'v',
|
|
76
|
+
arrowLeft: '<',
|
|
77
|
+
arrowRight: '>',
|
|
74
78
|
ellipsis: '...',
|
|
75
79
|
search: '/',
|
|
76
80
|
radioOn: '(*)',
|