@textui/core 0.6.1 → 0.8.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 (46) hide show
  1. package/dist/app/app.d.ts.map +1 -1
  2. package/dist/app/app.js +8 -1
  3. package/dist/core/focus.d.ts.map +1 -1
  4. package/dist/core/focus.js +33 -3
  5. package/dist/core/i18n.d.ts +6 -3
  6. package/dist/core/i18n.d.ts.map +1 -1
  7. package/dist/core/i18n.js +10 -6
  8. package/dist/jsx/intrinsics.d.ts +23 -0
  9. package/dist/jsx/intrinsics.d.ts.map +1 -1
  10. package/dist/runtime/hooks.d.ts.map +1 -1
  11. package/dist/runtime/hooks.js +15 -0
  12. package/dist/runtime/paint.js +12 -2
  13. package/dist/runtime/style.d.ts +17 -7
  14. package/dist/runtime/style.d.ts.map +1 -1
  15. package/dist/runtime/style.js +66 -6
  16. package/dist/themes/builtin.d.ts +0 -9
  17. package/dist/themes/builtin.d.ts.map +1 -1
  18. package/dist/themes/builtin.js +101 -4
  19. package/dist/themes/registry.d.ts.map +1 -1
  20. package/dist/themes/registry.js +26 -0
  21. package/dist/types/focus.d.ts +11 -0
  22. package/dist/types/focus.d.ts.map +1 -1
  23. package/dist/types/i18n.d.ts +14 -3
  24. package/dist/types/i18n.d.ts.map +1 -1
  25. package/dist/types/style.d.ts +53 -14
  26. package/dist/types/style.d.ts.map +1 -1
  27. package/dist/types/theme.d.ts +37 -3
  28. package/dist/types/theme.d.ts.map +1 -1
  29. package/dist/util/text.d.ts +9 -0
  30. package/dist/util/text.d.ts.map +1 -1
  31. package/dist/util/text.js +16 -0
  32. package/package.json +1 -1
  33. package/src/app/app.ts +8 -1
  34. package/src/core/focus.ts +30 -3
  35. package/src/core/i18n.ts +10 -6
  36. package/src/jsx/intrinsics.ts +25 -0
  37. package/src/runtime/hooks.ts +15 -0
  38. package/src/runtime/paint.ts +11 -2
  39. package/src/runtime/style.ts +73 -9
  40. package/src/themes/builtin.ts +105 -4
  41. package/src/themes/registry.ts +23 -0
  42. package/src/types/focus.ts +8 -0
  43. package/src/types/i18n.ts +14 -3
  44. package/src/types/style.ts +85 -18
  45. package/src/types/theme.ts +37 -3
  46. package/src/util/text.ts +15 -0
package/src/core/focus.ts CHANGED
@@ -223,12 +223,29 @@ export class Focus implements FocusManager {
223
223
  return (n.scopeId ?? GLOBAL_SCOPE) === scope;
224
224
  });
225
225
 
226
- return candidates
227
- .map((n, i) => ({ n, i }))
226
+ // A tree is placed where its first control registered, and its controls
227
+ // by where they sit in it. Registration order alone put a control that
228
+ // mounted late - a field a choice revealed - after everything that was
229
+ // already there, however far up the form it is drawn.
230
+ const firstOf = new Map<unknown, number>();
231
+ const entries = candidates.map((n, i) => {
232
+ const place = n.place?.();
233
+ const root = place ? place.root : n;
234
+ if (!firstOf.has(root)) firstOf.set(root, i);
235
+ return { n, i, root, path: place?.path };
236
+ });
237
+
238
+ return entries
228
239
  .sort((a, b) => {
229
240
  const ao = a.n.order ?? Number.MAX_SAFE_INTEGER;
230
241
  const bo = b.n.order ?? Number.MAX_SAFE_INTEGER;
231
- return ao === bo ? a.i - b.i : ao - bo;
242
+ if (ao !== bo) return ao - bo;
243
+ if (a.root !== b.root) return (firstOf.get(a.root) as number) - (firstOf.get(b.root) as number);
244
+ if (a.path && b.path) {
245
+ const by = comparePaths(a.path, b.path);
246
+ if (by !== 0) return by;
247
+ }
248
+ return a.i - b.i;
232
249
  })
233
250
  .map((e) => e.n.id);
234
251
  }
@@ -367,3 +384,13 @@ export class Focus implements FocusManager {
367
384
  export function createFocus(onChange?: () => void): Focus {
368
385
  return new Focus(onChange);
369
386
  }
387
+
388
+ /** Document order of two places in one tree; an ancestor comes first. */
389
+ function comparePaths(a: number[], b: number[]): number {
390
+ const length = Math.min(a.length, b.length);
391
+ for (let i = 0; i < length; i++) {
392
+ const by = (a[i] as number) - (b[i] as number);
393
+ if (by !== 0) return by;
394
+ }
395
+ return a.length - b.length;
396
+ }
package/src/core/i18n.ts CHANGED
@@ -57,18 +57,22 @@ export class I18nRegistry implements I18n {
57
57
  return [...this.bundles.keys()];
58
58
  }
59
59
 
60
- /** Missing keys fall through to the fallback locale, then to the key. */
61
- t(key: string, values?: Record<string, unknown>): string {
60
+ /**
61
+ * Missing keys fall through to the base language, then the fallback locale,
62
+ * then `fallback`, then the key.
63
+ */
64
+ t(key: string, values?: Record<string, unknown>, fallback?: string): string {
62
65
  const primary = this.bundles.get(this.locale);
63
66
  const base = this.locale.includes('-')
64
67
  ? this.bundles.get(this.locale.split('-')[0] as string)
65
68
  : undefined;
66
- const fallback = this.bundles.get(this.fallbackLocale);
69
+ const secondary = this.bundles.get(this.fallbackLocale);
67
70
 
68
71
  const template =
69
72
  (primary && lookup(primary, key)) ??
70
73
  (base && lookup(base, key)) ??
71
- (fallback && lookup(fallback, key)) ??
74
+ (secondary && lookup(secondary, key)) ??
75
+ fallback ??
72
76
  key;
73
77
 
74
78
  return values ? interpolate(template, values) : template;
@@ -90,10 +94,10 @@ export class I18nRegistry implements I18n {
90
94
  return new Intl.ListFormat(this.locale, options).format(items);
91
95
  }
92
96
 
93
- plural(count: number, forms: Record<string, string>): string {
97
+ plural(count: number, forms: Record<string, string>, values?: Record<string, unknown>): string {
94
98
  const rule = new Intl.PluralRules(this.locale).select(count);
95
99
  const template = forms[rule] ?? forms.other ?? '';
96
- return interpolate(template, { count });
100
+ return interpolate(template, { ...values, count });
97
101
  }
98
102
 
99
103
  onChange(fn: (locale: LocaleId) => void): Disposable {
@@ -24,6 +24,31 @@ export interface BaseProps extends Style {
24
24
  disabled?: boolean;
25
25
  selected?: boolean;
26
26
 
27
+ /**
28
+ * The states this node is in, for `style` overlays and for a theme.
29
+ *
30
+ * `focused` is a tri-state on purpose. Left out, the runtime asks the focus
31
+ * manager - which is right for a control and wrong for a row: a list row does
32
+ * not hold the keyboard, the list does. A row that has to be told so, or the
33
+ * distinction between "this is the current row" and "this is the current row
34
+ * and you can type at it" cannot be drawn at all.
35
+ */
36
+ focused?: boolean;
37
+
38
+ /**
39
+ * Which of the component's boxes a theme styles.
40
+ *
41
+ * A composite component draws plain `box` nodes, so without this its entry
42
+ * in a theme's `components` map is a key nothing reads - a list row is a
43
+ * `box`, and `components.List.selected` is what a theme author would reach
44
+ * for. The name has to be stated by the component that owns the box, which
45
+ * is the only place that knows what the row is part of.
46
+ *
47
+ * Omitted, the node is styled under its own host name, so `components.box`
48
+ * and a `variant` keep working exactly as they did.
49
+ */
50
+ styleAs?: string;
51
+
27
52
  /** Participates in tab order. Implied by an interactive role. */
28
53
  focusable?: boolean;
29
54
  /** The focus scope this node belongs to. */
@@ -556,6 +556,20 @@ export function focusScopeOf(instance: Instance): string | undefined {
556
556
  return typeof found === 'string' ? found : undefined;
557
557
  }
558
558
 
559
+ /** Where an instance sits: its root, and its child index at each level. */
560
+ function placeOf(instance: Instance): { root: unknown; path: number[] } | undefined {
561
+ const path: number[] = [];
562
+ let cursor = instance;
563
+ while (cursor.parent) {
564
+ const at = cursor.parent.children.indexOf(cursor);
565
+ // Rendered but not yet adopted, or already dropped: no place to answer.
566
+ if (at < 0) return undefined;
567
+ path.unshift(at);
568
+ cursor = cursor.parent;
569
+ }
570
+ return { root: cursor, path };
571
+ }
572
+
559
573
  export function useFocus(options: UseFocusOptions = {}): FocusHandle {
560
574
  const instance = currentInstance();
561
575
  const runtime = instance.runtime;
@@ -575,6 +589,7 @@ export function useFocus(options: UseFocusOptions = {}): FocusHandle {
575
589
  skipTab: options.skipTab,
576
590
  order: options.order,
577
591
  scopeId,
592
+ place: () => placeOf(instance),
578
593
  onFocus: () => {
579
594
  invalidate(instance, 'focus');
580
595
  options.onFocus?.();
@@ -12,7 +12,7 @@ import type { Buffer } from '../render/buffer.js';
12
12
  import { COLOR_DEFAULT, mix, packColor, type PackedColor } from '../render/color.js';
13
13
  import { rectIntersect } from '../types/geometry.js';
14
14
  import {
15
- graphemes, graphemeWidth, isAscii, sanitize, stringWidth, truncate,
15
+ graphemes, graphemeWidth, isAscii, markCut, sanitize, stringWidth, truncate,
16
16
  truncateSideOf, wrapModeOf, wrapText,
17
17
  } from '../util/text.js';
18
18
  import {
@@ -751,7 +751,16 @@ function paintText(
751
751
 
752
752
  for (let i = 0; i < raw.length && i < area.height; i++) {
753
753
  let line = raw[i] as string;
754
- if (stringWidth(line) > area.width) {
754
+ // The last line the box has room for is cut when the text goes on past
755
+ // it, exactly as a line too wide for the box is. Without this a wrapped
756
+ // paragraph with more to say stopped mid-sentence and read as the whole
757
+ // of it, which is the failure `truncate` exists to prevent sideways.
758
+ const stopped = i === area.height - 1 && raw.length > area.height;
759
+ if (stopped) {
760
+ // The last row it has room for, with more to say: always marked, since
761
+ // the row itself may fit to the cell.
762
+ line = truncateSide === false ? line : markCut(line, area.width, ellipsis);
763
+ } else if (stringWidth(line) > area.width) {
755
764
  line = truncateSide === false
756
765
  ? line
757
766
  : truncate(line, area.width, ellipsis, truncateSide ?? 'end');
@@ -1,4 +1,4 @@
1
- import type { Style, StatefulStyle, StyleInput, BorderSpec, BorderStyle, StyleColor } from '../types/style.js';
1
+ import type { Style, StatefulStyle, StyleInput, BorderSpec, BorderColor, BorderStyle, StyleColor, StateName } from '../types/style.js';
2
2
  import type { ResolvedTheme } from '../types/theme.js';
3
3
  import type { Edges } from '../types/geometry.js';
4
4
  import type { Color } from '../types/cells.js';
@@ -31,6 +31,7 @@ export const STYLE_KEYS = new Set<string>([
31
31
  export interface InteractionState {
32
32
  focused: boolean;
33
33
  hovered: boolean;
34
+ /** Pressed. Not a selection - that is `selected`. */
34
35
  active: boolean;
35
36
  selected: boolean;
36
37
  disabled: boolean;
@@ -40,6 +41,37 @@ export const NO_INTERACTION: InteractionState = {
40
41
  focused: false, hovered: false, active: false, selected: false, disabled: false,
41
42
  };
42
43
 
44
+ /**
45
+ * The states, least to most specific - the order the last one wins.
46
+ *
47
+ * `flattenStyleInput` merges a `style` prop in this order and so does a
48
+ * theme's per-component map, which is the only way the two can be made to
49
+ * agree: a theme states `focus` over `selected` and gets the same answer
50
+ * whether the state came from the node or from the theme. `hovered` is the
51
+ * state; `hover` is the name it wears, which is why this is the one place
52
+ * that has to know the difference.
53
+ */
54
+ const STATE_ORDER: readonly (keyof InteractionState)[] = [
55
+ 'selected', 'hovered', 'active', 'focused', 'disabled',
56
+ ];
57
+
58
+ const STATE_VARIANT: Record<keyof InteractionState, StateName> = {
59
+ selected: 'selected',
60
+ hovered: 'hover',
61
+ active: 'active',
62
+ focused: 'focus',
63
+ disabled: 'disabled',
64
+ };
65
+
66
+ /** The names of the states that are true, in the order the last one wins. */
67
+ export function stateVariants(state: InteractionState): StateName[] {
68
+ const out: StateName[] = [];
69
+ for (const key of STATE_ORDER) {
70
+ if (state[key]) out.push(STATE_VARIANT[key]);
71
+ }
72
+ return out;
73
+ }
74
+
43
75
  function isStateful(value: Style | StatefulStyle): value is StatefulStyle {
44
76
  return (
45
77
  'base' in value || 'focus' in value || 'hover' in value ||
@@ -68,6 +100,7 @@ export function flattenStyleInput(input: StyleInput | undefined, state: Interact
68
100
 
69
101
  // Order matters: selected loses to active, active loses to focus, and
70
102
  // disabled wins over everything - a disabled control is not focusable.
103
+ // The same order `stateVariants` gives a theme, so the two cannot disagree.
71
104
  return mergeStyles(
72
105
  input.base,
73
106
  state.selected ? input.selected : undefined,
@@ -94,13 +127,37 @@ export function resolveStyle(
94
127
  defaultStyle: Style | undefined,
95
128
  state: InteractionState,
96
129
  ): Style {
97
- const variants: string[] = [];
98
- if (typeof props.variant === 'string') variants.push(props.variant);
99
- if (typeof props.tone === 'string') variants.push(props.tone);
100
- if (typeof props.size === 'string') variants.push(props.size);
130
+ const qualifiers: string[] = [];
131
+ if (typeof props.variant === 'string') qualifiers.push(props.variant);
132
+ if (typeof props.tone === 'string') qualifiers.push(props.tone);
133
+ if (typeof props.size === 'string') qualifiers.push(props.size);
134
+ const variants: string[] = [...qualifiers];
135
+
136
+ // The states join last, so a theme's entry for one of them wins over the
137
+ // same name used as a variant - `List.focused` is the fill on the row the
138
+ // keyboard is on, and a `focused` variant means nothing else.
139
+ //
140
+ // Each state is also offered qualified by each of the props-driven names,
141
+ // immediately after the flat one, for the case the flat name cannot say:
142
+ // whether a state paints at all is sometimes a property of a variant rather
143
+ // than of the state. A solid tab is filled and an underline one is not, and
144
+ // `Tabs.selected` has to mean the pair for both while `Tabs.solid.selected`
145
+ // is the one that adds the fill. The qualified name merges last and the
146
+ // order between the states is unchanged, so `disabled` still wins over
147
+ // everything and `focus` still wins over `selected`.
148
+ for (const name of stateVariants(state)) {
149
+ variants.push(name);
150
+ for (const qualifier of qualifiers) variants.push(`${qualifier}.${name}`);
151
+ }
152
+
153
+ // `styleAs` is how a component says which of its boxes a theme styles. A
154
+ // list draws its rows as plain `box` nodes, so without it `components.List`
155
+ // would be a key nothing ever reads - which is what every `components` entry
156
+ // written for a composite component was until this.
157
+ const owner = typeof props.styleAs === 'string' ? props.styleAs : component;
101
158
 
102
159
  return mergeStyles(
103
- theme.styleFor(component, variants),
160
+ theme.styleFor(owner, variants),
104
161
  defaultStyle,
105
162
  styleFromProps(props),
106
163
  flattenStyleInput(props.style as StyleInput | undefined, state),
@@ -109,7 +166,13 @@ export function resolveStyle(
109
166
 
110
167
  // ------------------------------------------------------------------ colour
111
168
 
112
- /** A token name, a literal colour, or nothing. */
169
+ /**
170
+ * A token name, a literal colour, or nothing.
171
+ *
172
+ * Takes the whole union because it is the one place a colour is turned into
173
+ * a cell value: the narrowing happened at the field that named it, which is
174
+ * where the mistake is made and where the error belongs.
175
+ */
113
176
  export function resolveColor(
114
177
  value: StyleColor | undefined,
115
178
  theme: ResolvedTheme,
@@ -119,6 +182,7 @@ export function resolveColor(
119
182
  return theme.color(value as string);
120
183
  }
121
184
 
185
+ /** Pack a colour for a cell, at the caller's own channel. */
122
186
  export function packStyleColor(
123
187
  value: StyleColor | undefined,
124
188
  theme: ResolvedTheme,
@@ -144,9 +208,9 @@ export function attrsFromStyle(style: Style): number {
144
208
  export interface ResolvedBorder {
145
209
  style: BorderStyle;
146
210
  chars: BorderChars;
147
- color: StyleColor | undefined;
211
+ color: BorderColor | undefined;
148
212
  /** Per-edge overrides. Undefined here means "use `color`". */
149
- colors: { top?: StyleColor; right?: StyleColor; bottom?: StyleColor; left?: StyleColor };
213
+ colors: { top?: BorderColor; right?: BorderColor; bottom?: BorderColor; left?: BorderColor };
150
214
  dim: boolean;
151
215
  sides: { top: boolean; right: boolean; bottom: boolean; left: boolean };
152
216
  edges: Edges;
@@ -1,3 +1,4 @@
1
+ import type { Style } from '../types/style.js';
1
2
  import type { ThemeDefinition } from '../types/theme.js';
2
3
 
3
4
  /**
@@ -10,6 +11,72 @@ import type { ThemeDefinition } from '../types/theme.js';
10
11
  * difference lives here, in border style and density, not in the catalog.
11
12
  */
12
13
 
14
+ /**
15
+ * A selection, in the two states it has.
16
+ *
17
+ * `selected` is "this is the current one" and `focus` is the same row while
18
+ * the component holds the keyboard, so a theme states the bright fill once and
19
+ * the dimmer one for everything the reader has walked away from. They were one
20
+ * name once - `active` meant both this and pressed - and a word that means two
21
+ * things gets filled with either.
22
+ *
23
+ * Both name tokens rather than colours, which is the point of the whole
24
+ * arrangement: retint the palette and every component that has not said
25
+ * otherwise moves with it, while a theme that states `components.List.focused`
26
+ * moves the list and leaves the table where it was.
27
+ */
28
+ const SELECTION_FOCUSED: Style = { bg: 'selected', fg: 'onSelected', dim: true };
29
+ const SELECTION_UNFOCUSED: Style = { bg: 'active', fg: 'onActive' };
30
+ const SELECTION_HOVER: Style = { bg: 'hover', fg: 'onActive' };
31
+ // The other way to draw a selection: reverse video, which is legible whatever
32
+ // a theme's own two colours are. Left beside the three above while that is
33
+ // decided - nothing reads them yet.
34
+ // const SELECTION_DEFAULT: Style = { bg: 'default', fg: 'default', inverse: true };
35
+ // const SELECTION_DIMMED: Style = { bg: 'default', fg: 'default', inverse: true, dim: true };
36
+
37
+ /**
38
+ * The state colours every built-in theme starts from.
39
+ *
40
+ * Stated once and spread into `dark` and `light` because a theme that
41
+ * extends either inherits it whole: the entries name tokens, so `console`
42
+ * keeps its own `selected` and gets its own fills, without restating a single
43
+ * component. That is also what makes them the *defaults* - a theme states
44
+ * `components` to differ, and says nothing here about what it does not.
45
+ */
46
+ const STATE_STYLES: ThemeDefinition['components'] = {
47
+ List: { selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
48
+ Tree: { selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
49
+ Table: { selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
50
+ // The field's own selection, over the text it covers. Same pair, same
51
+ // reason: a selection left visible in an unfocused field says what is on
52
+ // the clipboard, and saying it as loudly as the live one would put two
53
+ // selections on the screen.
54
+ TextArea: { selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
55
+ // A marked line is a selection. The caret line is that selection plus the
56
+ // keyboard, so it takes the next fill up rather than a colour of its own.
57
+ CodeViewer: { selected: SELECTION_UNFOCUSED, focus: SELECTION_HOVER },
58
+ Menu: { selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
59
+ // A tab is open, not selected. Dimming it when the strip does not have the
60
+ // keyboard would say no document is open, which is a different claim and a
61
+ // wrong one - so it has one state and no second.
62
+ //
63
+ // Whether a tab is *filled* is a property of the variant rather than of the
64
+ // state: a solid tab is, an underline one is not. So the pair is stated for
65
+ // the state and the fill only for the variant that has one.
66
+ Tabs: { selected: { fg: 'onSelected' }, 'solid.selected': SELECTION_FOCUSED },
67
+ // The chat rows take the same fills, and for the same reason. `ToolCallRow`
68
+ // and `ReasoningBlock` are selections in a transcript that the caller
69
+ // names, so they have `selected` and no `focus`; a `ComposerChip` lights up
70
+ // only while it holds the keyboard, so it has `focus` and no `selected`.
71
+ ToolCallRow: { selected: SELECTION_FOCUSED },
72
+ ReasoningBlock: { selected: SELECTION_FOCUSED },
73
+ ComposerChip: { focus: SELECTION_FOCUSED },
74
+ // A range in a document is a selection, and it keeps the colours the text
75
+ // under it was already drawn in - the syntax of a selection is the syntax of
76
+ // the code it covers.
77
+ Editor: { selected: { bg: 'active' } },
78
+ };
79
+
13
80
  export const DARK: ThemeDefinition = {
14
81
  id: 'dark',
15
82
  name: 'Dark',
@@ -55,6 +122,7 @@ export const DARK: ThemeDefinition = {
55
122
  shadow: '#010409',
56
123
  },
57
124
  spacing: { none: 0, xs: 0, sm: 1, md: 1, lg: 2, xl: 3 },
125
+ components: { ...STATE_STYLES },
58
126
  };
59
127
 
60
128
  export const LIGHT: ThemeDefinition = {
@@ -93,7 +161,7 @@ export const LIGHT: ThemeDefinition = {
93
161
  onWarning: '#ffffff',
94
162
  onDanger: '#ffffff',
95
163
  hover: '#eaeef2',
96
- active: '#dbeafe',
164
+ active: '#a5bdd8',
97
165
  selected: '#0969da',
98
166
  focus: '#0969da',
99
167
  disabled: '#8c959f',
@@ -102,6 +170,7 @@ export const LIGHT: ThemeDefinition = {
102
170
  shadow: '#d0d7de',
103
171
  },
104
172
  spacing: { none: 0, xs: 0, sm: 1, md: 1, lg: 2, xl: 3 },
173
+ components: { ...STATE_STYLES },
105
174
  };
106
175
 
107
176
  /** Dense, bordered, high contrast. Every region is a labelled box. */
@@ -127,7 +196,9 @@ export const CONSOLE: ThemeDefinition = {
127
196
  //
128
197
  // The two selection backgrounds are picked rather than derived: `selected`
129
198
  // carries `inverted` text so it has to be light, and `active` carries
130
- // `text` so it has to be dark. Same hue, opposite ends.
199
+ // `text` so it has to be dark. Same hue, opposite ends. `active` is what
200
+ // a selection looks like when the component does not have the keyboard -
201
+ // it is the dim end of the pair, not a pressed control.
131
202
  primary: '#88c0d0',
132
203
  info: '#88c0d0',
133
204
  selected: '#6ba3b2',
@@ -172,6 +243,7 @@ export const PAPER: ThemeDefinition = {
172
243
  warning: '#d29922',
173
244
  danger: '#f85149',
174
245
  info: '#58a6ff',
246
+ /* ---- */
175
247
  onAccent: '#0d1117',
176
248
  onDefault: '#0d1117',
177
249
  onPrimary: '#0d1117',
@@ -181,17 +253,23 @@ export const PAPER: ThemeDefinition = {
181
253
  onInfo: '#0d1117',
182
254
  onWarning: '#0d1117',
183
255
  onDanger: '#0d1117',
256
+ /* ---- */
184
257
  hover: '#1f2937',
185
- active: '#264466',
186
- selected: '#1f6feb',
258
+ active: 'default',
259
+ onActive: '#3191ff',
260
+ selected: 'default',
261
+ onSelected: '#58a6ff',
187
262
  focus: '#58a6ff',
188
263
  disabled: '#484f58',
264
+
265
+ /* ---- */
189
266
  scrim: '#010409',
190
267
  cursor: 'default',
191
268
  shadow: '#010409',
192
269
  },
193
270
  spacing: { none: 0, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 },
194
271
  components: {
272
+ ...STATE_STYLES,
195
273
  Panel: { base: { border: 'none', padding: [1, 2] } },
196
274
  Button: { base: { padding: [0, 2] } },
197
275
  },
@@ -263,6 +341,9 @@ export const WORKBENCH: ThemeDefinition = {
263
341
  hover: '#313244',
264
342
  active: '#45475a',
265
343
  selected: '#585b70',
344
+ // The selection is a mid grey, so the light text is what reads on it; the
345
+ // inherited `inverted` is darker than the fill and nearly disappears.
346
+ onSelected: '#cdd6f4',
266
347
  focus: '#89b4fa',
267
348
  scrim: '#11111b',
268
349
  },
@@ -276,7 +357,12 @@ export const MONO: ThemeDefinition = {
276
357
  id: 'mono',
277
358
  name: 'Monochrome',
278
359
  appearance: 'dark',
360
+ // The theme's own colours are all `default`; this is what makes that true of
361
+ // the ones a component states for itself, which is the difference between a
362
+ // palette that happens to be grey and a theme with no colour in it.
363
+ monochrome: true,
279
364
  border: 'ascii',
365
+ // border: 'none',
280
366
  cursor: 'underline',
281
367
  // Chosen, not downgraded to: this theme is ascii on a terminal that could
282
368
  // draw anything, so the rule has to say so too.
@@ -317,6 +403,21 @@ export const MONO: ThemeDefinition = {
317
403
  cursor: 'default',
318
404
  shadow: 'default',
319
405
  },
406
+ components: {
407
+ List: { selected: { bold: true }, focus: { bold: true } },
408
+ Tree: { selected: { bold: true }, focus: { bold: true } },
409
+ Table: { selected: { bold: true }, focus: { bold: true } },
410
+ TextArea: { selected: { bold: true }, focus: { bold: true } },
411
+ CodeViewer: { selected: { bold: true }, focus: { bold: true } },
412
+ Menu: { selected: { bold: true }, focus: { bold: true } },
413
+ Tabs: { selected: { bold: true }, 'solid.selected': { bold: true } },
414
+ ToolCallRow: { selected: { bold: true } },
415
+ ReasoningBlock: { selected: { bold: true } },
416
+ ComposerChip: { focus: { bold: true } },
417
+ Editor: { selected: { bold: true } },
418
+ Panel: { base: { border: 'ascii', padding: [0, 1] } },
419
+ Button: { base: { padding: [0, 1] } },
420
+ }
320
421
  };
321
422
 
322
423
  /**
@@ -24,6 +24,7 @@ const FALLBACK_COLORS: Record<ColorToken, Color> = {
24
24
  onDefault: 'default', onMuted: 'default',
25
25
  onAccent: 'default', onPrimary: 'default', onSecondary: 'default',
26
26
  onSuccess: 'default', onWarning: 'default', onDanger: 'default', onInfo: 'default',
27
+ onSelected: 'default', onActive: 'default',
27
28
  hover: 'default', active: 'default', selected: 'default', focus: 'default',
28
29
  disabled: 'default', scrim: 'default', cursor: 'default', shadow: 'default',
29
30
  };
@@ -125,6 +126,7 @@ export class Themes implements ThemeRegistry {
125
126
  // opts in rather than out.
126
127
  let tableRules: TableRules = 'header';
127
128
  let density: Density = 'normal';
129
+ let monochrome = false;
128
130
  const components: Record<string, Record<string, Style>> = {};
129
131
  let syntaxOverrides: Partial<Record<SyntaxScope, StyleColor>> = {};
130
132
 
@@ -155,11 +157,27 @@ export class Themes implements ThemeRegistry {
155
157
  if (def.cursor) cursorStyle = def.cursor;
156
158
  if (def.tableRules) tableRules = def.tableRules;
157
159
  if (def.density) density = def.density;
160
+ if (def.monochrome !== undefined) monochrome = def.monochrome;
158
161
  for (const [name, variants] of Object.entries(def.components ?? {})) {
159
162
  components[name] = { ...components[name], ...variants };
160
163
  }
161
164
  }
162
165
 
166
+ // What is written on a filled row, derived rather than restated.
167
+ //
168
+ // A selection background is only half a pair: `selected` carries
169
+ // `inverted` and `active` carries `text`. Stated as two independent
170
+ // colours, a theme that restates one half - `paper-light` restates
171
+ // `inverted`, `paper` leaves `text` as the terminal's - would take the
172
+ // other half from the theme it extends, or from the terminal, and a row
173
+ // could come out unreadable with nothing in the theme saying so.
174
+ //
175
+ // So the pair follows the theme's own values unless it says otherwise. A
176
+ // theme that wants a different colour on a fill states it, which is what
177
+ // `paper` does for `active`.
178
+ if (colors.onSelected === 'default') colors.onSelected = colors.inverted;
179
+ if (colors.onActive === 'default') colors.onActive = colors.text;
180
+
163
181
  // A colourless terminal gets no colour, whatever the theme says.
164
182
  if (caps.colorDepth === 0) {
165
183
  for (const token of Object.keys(colors) as ColorToken[]) {
@@ -190,6 +208,7 @@ export class Themes implements ThemeRegistry {
190
208
  id: leaf.id,
191
209
  name: leaf.name,
192
210
  appearance: leaf.appearance,
211
+ monochrome,
193
212
  colors,
194
213
  spacing,
195
214
  glyphs,
@@ -202,6 +221,10 @@ export class Themes implements ThemeRegistry {
202
221
  syntax,
203
222
 
204
223
  color(token: string): Color {
224
+ // A monochrome theme has no colour to give, and that has to include the
225
+ // literal a component states for itself - otherwise the one theme whose
226
+ // whole point is no colour is the one that cannot promise it.
227
+ if (monochrome) return 'default';
205
228
  if (token in colors) return colors[token as ColorToken] as Color;
206
229
  // Not a token: a literal colour passed straight through.
207
230
  return token as Color;
@@ -25,6 +25,14 @@ export interface FocusableOptions {
25
25
  id: string;
26
26
  /** Explicit order within the scope. Unset = document order. */
27
27
  order?: number;
28
+ /**
29
+ * Where the focusable sits in the tree, asked each time the tab order is.
30
+ *
31
+ * `root` is the tree it is in and `path` the child index at each level
32
+ * below it. Left off, or answering `undefined`, the focusable is placed by
33
+ * when it registered.
34
+ */
35
+ place?(): { root: unknown; path: number[] } | undefined;
28
36
  disabled?: boolean;
29
37
  /** Skipped by tab, still reachable by directional navigation and click. */
30
38
  skipTab?: boolean;
package/src/types/i18n.ts CHANGED
@@ -13,13 +13,24 @@ export interface I18n {
13
13
  setLocale(locale: LocaleId): void;
14
14
  register(bundle: TranslationBundle): Disposable;
15
15
  locales(): LocaleId[];
16
- /** Missing keys fall back to the fallback locale, then to the key itself. */
17
- t(key: string, values?: Record<string, unknown>): string;
16
+ /**
17
+ * Missing keys fall back to the fallback locale, then to `fallback` (the
18
+ * text a component draws when nobody translated it), then to the key itself.
19
+ */
20
+ t(key: string, values?: Record<string, unknown>, fallback?: string): string;
18
21
  /** Intl-backed; TextUI does not reimplement formatting. */
19
22
  number(value: number, options?: Intl.NumberFormatOptions): string;
20
23
  date(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
21
24
  relative(value: number, unit: Intl.RelativeTimeFormatUnit): string;
22
25
  list(items: string[], options?: Intl.ListFormatOptions): string;
23
- plural(count: number, forms: Record<string, string>): string;
26
+ /**
27
+ * The form the locale's plural rules pick for `count`, with `{count}` and
28
+ * anything the sentence needs around it.
29
+ *
30
+ * `values` carries what else the form names - a limit, a glyph, the total a
31
+ * count is out of - because a sentence that inflects a noun is the whole
32
+ * sentence and not a noun with a number in front of it.
33
+ */
34
+ plural(count: number, forms: Record<string, string>, values?: Record<string, unknown>): string;
24
35
  onChange(fn: (locale: LocaleId) => void): Disposable;
25
36
  }