@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.
Files changed (70) hide show
  1. package/README.md +17 -20
  2. package/dist/app/app.d.ts +64 -0
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +202 -1
  5. package/dist/core/focus.d.ts.map +1 -1
  6. package/dist/core/focus.js +12 -1
  7. package/dist/core/store.d.ts.map +1 -1
  8. package/dist/core/store.js +2 -10
  9. package/dist/jsx/intrinsics.d.ts +18 -0
  10. package/dist/jsx/intrinsics.d.ts.map +1 -1
  11. package/dist/render/layout.js +10 -2
  12. package/dist/runtime/bindings.d.ts.map +1 -1
  13. package/dist/runtime/bindings.js +31 -4
  14. package/dist/runtime/hooks.d.ts +14 -2
  15. package/dist/runtime/hooks.d.ts.map +1 -1
  16. package/dist/runtime/hooks.js +15 -4
  17. package/dist/themes/builtin.d.ts +2 -0
  18. package/dist/themes/builtin.d.ts.map +1 -1
  19. package/dist/themes/builtin.js +64 -1
  20. package/dist/themes/dividers.d.ts +14 -0
  21. package/dist/themes/dividers.d.ts.map +1 -0
  22. package/dist/themes/dividers.js +42 -0
  23. package/dist/themes/glyphs.d.ts.map +1 -1
  24. package/dist/themes/glyphs.js +4 -0
  25. package/dist/themes/index.d.ts +1 -0
  26. package/dist/themes/index.d.ts.map +1 -1
  27. package/dist/themes/index.js +1 -0
  28. package/dist/themes/registry.d.ts.map +1 -1
  29. package/dist/themes/registry.js +31 -1
  30. package/dist/types/app.d.ts +15 -0
  31. package/dist/types/app.d.ts.map +1 -1
  32. package/dist/types/command.d.ts +18 -1
  33. package/dist/types/command.d.ts.map +1 -1
  34. package/dist/types/input.d.ts +9 -0
  35. package/dist/types/input.d.ts.map +1 -1
  36. package/dist/types/markdown.d.ts +36 -1
  37. package/dist/types/markdown.d.ts.map +1 -1
  38. package/dist/types/style.d.ts +28 -1
  39. package/dist/types/style.d.ts.map +1 -1
  40. package/dist/types/terminal.d.ts +9 -0
  41. package/dist/types/terminal.d.ts.map +1 -1
  42. package/dist/types/theme.d.ts +26 -1
  43. package/dist/types/theme.d.ts.map +1 -1
  44. package/dist/util/markdown.d.ts.map +1 -1
  45. package/dist/util/markdown.js +172 -2
  46. package/dist/util/paths.d.ts +9 -3
  47. package/dist/util/paths.d.ts.map +1 -1
  48. package/dist/util/paths.js +11 -16
  49. package/package.json +5 -5
  50. package/src/app/app.ts +198 -1
  51. package/src/core/focus.ts +12 -1
  52. package/src/core/store.ts +2 -7
  53. package/src/jsx/intrinsics.ts +18 -0
  54. package/src/render/layout.ts +7 -2
  55. package/src/runtime/bindings.ts +29 -3
  56. package/src/runtime/hooks.ts +15 -4
  57. package/src/themes/builtin.ts +65 -1
  58. package/src/themes/dividers.ts +48 -0
  59. package/src/themes/glyphs.ts +4 -0
  60. package/src/themes/index.ts +1 -0
  61. package/src/themes/registry.ts +30 -2
  62. package/src/types/app.ts +13 -0
  63. package/src/types/command.ts +18 -1
  64. package/src/types/input.ts +9 -0
  65. package/src/types/markdown.ts +38 -2
  66. package/src/types/style.ts +33 -1
  67. package/src/types/terminal.ts +9 -0
  68. package/src/types/theme.ts +29 -1
  69. package/src/util/markdown.ts +202 -3
  70. 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
- if (!existing && options.disabled !== true && this.current === null) {
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
- ancestorKeys, hasWildcard, isDescendantKey, matchKey, pathKey, PathError,
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
- if (sub.key === changedKey) return true;
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
@@ -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. */
@@ -465,8 +465,13 @@ function layoutBox(box: LayoutBox, rect: Rect): void {
465
465
  return;
466
466
  }
467
467
 
468
- const flow = box.children.filter((c) => !isAbsolute(c) && !isHidden(c));
469
- const absolute = box.children.filter((c) => isAbsolute(c) && !isHidden(c));
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);
@@ -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
- if (Array.isArray(value)) return value.map((v) => resolveValue(ctx, v));
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
- out[k] = resolveValue(ctx, v);
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
  /**
@@ -1001,14 +1001,25 @@ export function useTicker(
1001
1001
  }, [enabled, options.fps]);
1002
1002
  }
1003
1003
 
1004
- /** A frame counter, for spinners and marquees. Frozen when animation is off. */
1005
- export function useFrame(fps = 10): number {
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: !disabled });
1011
- return disabled ? 0 : frame;
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. */
@@ -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
+ }
@@ -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: '(*)',
@@ -1,4 +1,5 @@
1
1
  export * from './borders.js';
2
+ export * from './dividers.js';
2
3
  export * from './glyphs.js';
3
4
  export * from './builtin.js';
4
5
  export * from './registry.js';