@textui/core 0.7.0 → 0.9.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.
@@ -45,6 +45,15 @@ export declare function sliceColumns(text: string, start: number, width: number)
45
45
  */
46
46
  export declare function expandTabs(text: string, tabWidth?: number): string;
47
47
  export type TruncateSide = 'end' | 'start' | 'middle';
48
+ /**
49
+ * Fit into `width` cells, marking the cut whether or not there was one.
50
+ *
51
+ * `truncate` marks only what it had to cut, which is right for a string that
52
+ * has to fit a cell. A paragraph stopped by the box rather than by its own
53
+ * length is the other case: its last visible row may fit exactly and still not
54
+ * be the end of the text, and it has to say so.
55
+ */
56
+ export declare function markCut(text: string, width: number, ellipsis?: string): string;
48
57
  /** Fit into `width` cells, marking the cut with `ellipsis`. */
49
58
  export declare function truncate(text: string, width: number, ellipsis?: string, side?: TruncateSide): string;
50
59
  /** Pad to exactly `width` cells. Over-long input is returned unchanged. */
@@ -1 +1 @@
1
- {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/util/text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAelD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAShD;AA8ED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsBrD;AAsBD,2CAA2C;AAC3C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBhD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAYhE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAgC/E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAuB7D;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtD,+DAA+D;AAC/D,wBAAgB,QAAQ,CACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,SAAM,EACd,IAAI,GAAE,YAAoB,GACzB,MAAM,CAkCR;AAED,2EAA2E;AAC3E,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,IAAI,SAAM,GACT,MAAM,CAQR;AAED,kEAAkE;AAClE,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,QAAQ,SAAM,GACb,MAAM,CAER;AAED,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAOnF;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,GAAG,MAAM,EAAE,CAuDvF;AAOD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAgBD;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7C;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjE"}
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/util/text.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAelD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAShD;AA8ED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsBrD;AAsBD,2CAA2C;AAC3C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBhD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAYhE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAgC/E;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAuB7D;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtD;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAK3E;AAED,+DAA+D;AAC/D,wBAAgB,QAAQ,CACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,SAAM,EACd,IAAI,GAAE,YAAoB,GACzB,MAAM,CAkCR;AAED,2EAA2E;AAC3E,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,IAAI,SAAM,GACT,MAAM,CAQR;AAED,kEAAkE;AAClE,wBAAgB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAC3C,QAAQ,SAAM,GACb,MAAM,CAER;AAED,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAOnF;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,QAAiB,GAAG,MAAM,EAAE,CAuDvF;AAOD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAgBD;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7C;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjE"}
package/dist/util/text.js CHANGED
@@ -280,6 +280,22 @@ export function expandTabs(text, tabWidth = 4) {
280
280
  })
281
281
  .join('\n');
282
282
  }
283
+ /**
284
+ * Fit into `width` cells, marking the cut whether or not there was one.
285
+ *
286
+ * `truncate` marks only what it had to cut, which is right for a string that
287
+ * has to fit a cell. A paragraph stopped by the box rather than by its own
288
+ * length is the other case: its last visible row may fit exactly and still not
289
+ * be the end of the text, and it has to say so.
290
+ */
291
+ export function markCut(text, width, ellipsis = '…') {
292
+ if (width <= 0)
293
+ return '';
294
+ const ew = stringWidth(ellipsis);
295
+ if (width <= ew)
296
+ return sliceByWidth(ellipsis, width);
297
+ return sliceByWidth(text, width - ew).trimEnd() + ellipsis;
298
+ }
283
299
  /** Fit into `width` cells, marking the cut with `ellipsis`. */
284
300
  export function truncate(text, width, ellipsis = '…', side = 'end') {
285
301
  const total = stringWidth(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textui/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Graph-mounted terminal UI runtime - store, registries, renderer, host primitives",
5
5
  "keywords": [
6
6
  "terminal",
package/src/app/app.ts CHANGED
@@ -752,8 +752,15 @@ export class App implements TextUIApp {
752
752
  private stateOf(instance: Instance): InteractionState {
753
753
  const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
754
754
  const focusedId = this.focus.focused();
755
+ // A node may state its own focus, and a row has to: the list holds the
756
+ // keyboard, the row is where it lands. Left to the id comparison every
757
+ // row in every list reads as unfocused, so "the current row" and "the
758
+ // current row you can type at" would be the same fill.
759
+ const stated = instance.props.focused;
755
760
  return {
756
- focused: focusedId === id || focusedId === `${instance.id}:focus`,
761
+ focused: typeof stated === 'boolean'
762
+ ? stated
763
+ : focusedId === id || focusedId === `${instance.id}:focus`,
757
764
  hovered: this.hovered === id || this.hoveredChain.has(instance),
758
765
  active: false,
759
766
  selected: instance.props.selected === true,
package/src/core/i18n.ts CHANGED
@@ -94,10 +94,10 @@ export class I18nRegistry implements I18n {
94
94
  return new Intl.ListFormat(this.locale, options).format(items);
95
95
  }
96
96
 
97
- plural(count: number, forms: Record<string, string>): string {
97
+ plural(count: number, forms: Record<string, string>, values?: Record<string, unknown>): string {
98
98
  const rule = new Intl.PluralRules(this.locale).select(count);
99
99
  const template = forms[rule] ?? forms.other ?? '';
100
- return interpolate(template, { count });
100
+ return interpolate(template, { ...values, count });
101
101
  }
102
102
 
103
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. */
@@ -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,74 @@ 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', bold: 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: { base: { fg: 'text' }, selected: SELECTION_UNFOCUSED, focus: SELECTION_FOCUSED },
59
+ TextInput: { base: { fg: 'text' } },
60
+ // A tab is open, not selected. Dimming it when the strip does not have the
61
+ // keyboard would say no document is open, which is a different claim and a
62
+ // wrong one - so it has one state and no second.
63
+ //
64
+ // Whether a tab is *filled* is a property of the variant rather than of the
65
+ // state: a solid tab is, an underline one is not. So the pair is stated for
66
+ // the state and the fill only for the variant that has one.
67
+ Tabs: { selected: { fg: 'onSelected' }, 'solid.selected': SELECTION_FOCUSED },
68
+ // The chat rows take the same fills, and for the same reason. `ToolCallRow`
69
+ // and `ReasoningBlock` are selections in a transcript that the caller
70
+ // names, so they have `selected` and no `focus`; a `ComposerChip` lights up
71
+ // only while it holds the keyboard, so it has `focus` and no `selected`.
72
+ ToolCallRow: { selected: SELECTION_FOCUSED },
73
+ ReasoningBlock: { selected: SELECTION_FOCUSED },
74
+ ComposerChip: { focus: SELECTION_FOCUSED },
75
+ // A range in a document is a selection, drawn in reverse video so it keeps
76
+ // the colours the text under it was already drawn in - the syntax of a
77
+ // selection is the syntax of the code it covers - and shows whether or not
78
+ // the theme's `active` is a fill.
79
+ Editor: { selected: { inverse: true } },
80
+ };
81
+
13
82
  export const DARK: ThemeDefinition = {
14
83
  id: 'dark',
15
84
  name: 'Dark',
@@ -25,9 +94,9 @@ export const DARK: ThemeDefinition = {
25
94
  borderStrong: '#484f58',
26
95
  borderSubtle: '#21262d',
27
96
  divider: '#484f58',
28
- text: '#e6edf3',
97
+ text: '#f3e6f1',
29
98
  muted: '#8b949e',
30
- subtle: '#6e7681',
99
+ subtle: '#546f94',
31
100
  inverted: '#0d1117',
32
101
  accent: '#58a6ff',
33
102
  primary: '#388bfd',
@@ -46,8 +115,10 @@ export const DARK: ThemeDefinition = {
46
115
  onWarning: '#0d1117',
47
116
  onDanger: '#0d1117',
48
117
  hover: '#1f2937',
49
- active: '#264466',
50
- selected: '#1f6feb',
118
+ active: 'default',
119
+ onActive: '#3191ff',
120
+ selected: 'default',
121
+ onSelected: '#3191ff',
51
122
  focus: '#58a6ff',
52
123
  disabled: '#484f58',
53
124
  scrim: '#010409',
@@ -55,6 +126,7 @@ export const DARK: ThemeDefinition = {
55
126
  shadow: '#010409',
56
127
  },
57
128
  spacing: { none: 0, xs: 0, sm: 1, md: 1, lg: 2, xl: 3 },
129
+ components: { ...STATE_STYLES },
58
130
  };
59
131
 
60
132
  export const LIGHT: ThemeDefinition = {
@@ -93,8 +165,10 @@ export const LIGHT: ThemeDefinition = {
93
165
  onWarning: '#ffffff',
94
166
  onDanger: '#ffffff',
95
167
  hover: '#eaeef2',
96
- active: '#dbeafe',
97
- selected: '#0969da',
168
+ active: 'default',
169
+ onActive: '#0969da',
170
+ selected: 'default',
171
+ onSelected: '#0969da',
98
172
  focus: '#0969da',
99
173
  disabled: '#8c959f',
100
174
  scrim: '#8c959f',
@@ -102,6 +176,7 @@ export const LIGHT: ThemeDefinition = {
102
176
  shadow: '#d0d7de',
103
177
  },
104
178
  spacing: { none: 0, xs: 0, sm: 1, md: 1, lg: 2, xl: 3 },
179
+ components: { ...STATE_STYLES },
105
180
  };
106
181
 
107
182
  /** Dense, bordered, high contrast. Every region is a labelled box. */
@@ -127,11 +202,16 @@ export const CONSOLE: ThemeDefinition = {
127
202
  //
128
203
  // The two selection backgrounds are picked rather than derived: `selected`
129
204
  // carries `inverted` text so it has to be light, and `active` carries
130
- // `text` so it has to be dark. Same hue, opposite ends.
205
+ // `text` so it has to be dark. Same hue, opposite ends. `active` is what
206
+ // a selection looks like when the component does not have the keyboard -
207
+ // it is the dim end of the pair, not a pressed control.
131
208
  primary: '#88c0d0',
132
209
  info: '#88c0d0',
133
210
  selected: '#6ba3b2',
134
211
  active: '#1e3d47',
212
+ // Stated, because `dark` states its own for a selection with no fill.
213
+ onSelected: '#0a0e14',
214
+ onActive: '#d8dee9',
135
215
  onAccent: '#0a0e14',
136
216
  onPrimary: '#0a0e14',
137
217
  onInfo: '#0a0e14',
@@ -172,6 +252,7 @@ export const PAPER: ThemeDefinition = {
172
252
  warning: '#d29922',
173
253
  danger: '#f85149',
174
254
  info: '#58a6ff',
255
+ /* ---- */
175
256
  onAccent: '#0d1117',
176
257
  onDefault: '#0d1117',
177
258
  onPrimary: '#0d1117',
@@ -181,17 +262,23 @@ export const PAPER: ThemeDefinition = {
181
262
  onInfo: '#0d1117',
182
263
  onWarning: '#0d1117',
183
264
  onDanger: '#0d1117',
265
+ /* ---- */
184
266
  hover: '#1f2937',
185
- active: '#264466',
186
- selected: '#1f6feb',
267
+ active: 'default',
268
+ onActive: '#3191ff',
269
+ selected: 'default',
270
+ onSelected: '#58a6ff',
187
271
  focus: '#58a6ff',
188
272
  disabled: '#484f58',
273
+
274
+ /* ---- */
189
275
  scrim: '#010409',
190
276
  cursor: 'default',
191
277
  shadow: '#010409',
192
278
  },
193
279
  spacing: { none: 0, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 },
194
280
  components: {
281
+ ...STATE_STYLES,
195
282
  Panel: { base: { border: 'none', padding: [1, 2] } },
196
283
  Button: { base: { padding: [0, 2] } },
197
284
  },
@@ -224,6 +311,9 @@ export const PAPER_LIGHT: ThemeDefinition = {
224
311
  hover: '#f7f1e7',
225
312
  active: '#efe4d2',
226
313
  selected: '#eadcc6',
314
+ // Stated, because `light` states its own for a selection with no fill.
315
+ onActive: '#2b2a27',
316
+ onSelected: '#2b2a27',
227
317
  inverted: '#2b2a27',
228
318
  focus: '#b4531f',
229
319
  },
@@ -234,6 +324,49 @@ export const PAPER_LIGHT: ThemeDefinition = {
234
324
  },
235
325
  };
236
326
 
327
+ /**
328
+ * Paper, after dark.
329
+ *
330
+ * The same airy, borderless, warm-accented character as `paper` - the point of
331
+ * it is the restraint, not the brightness - with the ink and the page swapped.
332
+ * A warm dark rather than a blue one, so the two read as one family and a
333
+ * reader moving between them is not moving between two different products.
334
+ */
335
+ export const PAPER_DARK: ThemeDefinition = {
336
+ id: 'paper-dark',
337
+ name: 'Paper Dark',
338
+ appearance: 'dark',
339
+ extends: 'dark',
340
+ border: 'none',
341
+ density: 'airy',
342
+ colors: {
343
+ canvas: '#1c1a17',
344
+ surface: '#1c1a17',
345
+ surfaceAlt: '#26231f',
346
+ border: '#3a352e',
347
+ borderSubtle: '#2a2621',
348
+ divider: 'default',
349
+ subtle: '#db8c4c',
350
+ text: '#e8e3d9',
351
+ muted: '#9a9287',
352
+ accent: '#e0873f',
353
+ primary: '#e0873f',
354
+ // The same restraint, the other way up.
355
+ hover: '#26231f',
356
+ active: 'default',
357
+ onActive: '#e0873f',
358
+ selected: 'default',
359
+ onSelected: '#e0873f',
360
+ inverted: '#f4efe6',
361
+ focus: '#e0873f',
362
+ },
363
+ spacing: { none: 0, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 },
364
+ components: {
365
+ Panel: { base: { border: 'none', padding: [1, 2] } },
366
+ Button: { base: { padding: [0, 2] } },
367
+ },
368
+ };
369
+
237
370
  /** Rounded chrome around a persistent frame. */
238
371
  export const WORKBENCH: ThemeDefinition = {
239
372
  id: 'workbench',
@@ -263,6 +396,10 @@ export const WORKBENCH: ThemeDefinition = {
263
396
  hover: '#313244',
264
397
  active: '#45475a',
265
398
  selected: '#585b70',
399
+ // The selection is a mid grey, so the light text is what reads on it; the
400
+ // inherited `inverted` is darker than the fill and nearly disappears.
401
+ onSelected: '#cdd6f4',
402
+ onActive: '#cdd6f4',
266
403
  focus: '#89b4fa',
267
404
  scrim: '#11111b',
268
405
  },
@@ -276,7 +413,12 @@ export const MONO: ThemeDefinition = {
276
413
  id: 'mono',
277
414
  name: 'Monochrome',
278
415
  appearance: 'dark',
416
+ // The theme's own colours are all `default`; this is what makes that true of
417
+ // the ones a component states for itself, which is the difference between a
418
+ // palette that happens to be grey and a theme with no colour in it.
419
+ monochrome: true,
279
420
  border: 'ascii',
421
+ // border: 'none',
280
422
  cursor: 'underline',
281
423
  // Chosen, not downgraded to: this theme is ascii on a terminal that could
282
424
  // draw anything, so the rule has to say so too.
@@ -317,47 +459,21 @@ export const MONO: ThemeDefinition = {
317
459
  cursor: 'default',
318
460
  shadow: 'default',
319
461
  },
320
- };
321
-
322
- /**
323
- * Paper, after dark.
324
- *
325
- * The same airy, borderless, warm-accented character as `paper` - the point of
326
- * it is the restraint, not the brightness - with the ink and the page swapped.
327
- * A warm dark rather than a blue one, so the two read as one family and a
328
- * reader moving between them is not moving between two different products.
329
- */
330
- export const PAPER_DARK: ThemeDefinition = {
331
- id: 'paper-dark',
332
- name: 'Paper Dark',
333
- appearance: 'dark',
334
- extends: 'dark',
335
- border: 'none',
336
- density: 'airy',
337
- colors: {
338
- canvas: '#1c1a17',
339
- surface: '#1c1a17',
340
- surfaceAlt: '#26231f',
341
- border: '#3a352e',
342
- borderSubtle: '#2a2621',
343
- divider: 'default',
344
- subtle: '#db8c4c',
345
- text: '#e8e3d9',
346
- muted: '#9a9287',
347
- accent: '#e0873f',
348
- primary: '#e0873f',
349
- // The same restraint, the other way up.
350
- hover: '#26231f',
351
- active: '#332e27',
352
- selected: '#3d362c',
353
- inverted: '#f4efe6',
354
- focus: '#e0873f',
355
- },
356
- spacing: { none: 0, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 },
357
462
  components: {
358
- Panel: { base: { border: 'none', padding: [1, 2] } },
359
- Button: { base: { padding: [0, 2] } },
360
- },
463
+ List: { selected: { bold: true }, focus: { bold: true } },
464
+ Tree: { selected: { bold: true }, focus: { bold: true } },
465
+ Table: { selected: { bold: true }, focus: { bold: true } },
466
+ TextArea: { selected: { bold: true }, focus: { bold: true } },
467
+ CodeViewer: { selected: { bold: true }, focus: { bold: true } },
468
+ Menu: { selected: { bold: true }, focus: { bold: true } },
469
+ Tabs: { selected: { bold: true }, 'solid.selected': { bold: true } },
470
+ ToolCallRow: { selected: { bold: true } },
471
+ ReasoningBlock: { selected: { bold: true } },
472
+ ComposerChip: { focus: { bold: true } },
473
+ Editor: { selected: { bold: true } },
474
+ Panel: { base: { border: 'ascii', padding: [0, 1] } },
475
+ Button: { base: { padding: [0, 1] } },
476
+ }
361
477
  };
362
478
 
363
479
  export const BUILTIN_THEMES: ThemeDefinition[] = [