@textui/core 0.7.0 → 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.
@@ -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;
package/src/types/i18n.ts CHANGED
@@ -23,6 +23,14 @@ export interface I18n {
23
23
  date(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
24
24
  relative(value: number, unit: Intl.RelativeTimeFormatUnit): string;
25
25
  list(items: string[], options?: Intl.ListFormatOptions): string;
26
- 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;
27
35
  onChange(fn: (locale: LocaleId) => void): Disposable;
28
36
  }
@@ -2,23 +2,77 @@ import type { Color } from './cells.js';
2
2
  import type { EdgeSpec } from './geometry.js';
3
3
 
4
4
  /**
5
- * Semantic theme tokens. A component names a role, never a colour - which is
6
- * what lets one catalog render the same under a light theme, a dark theme and
7
- * a 16-colour ssh session.
5
+ * Semantic theme tokens, split by the channel each one is written in.
6
+ *
7
+ * A component names a role, never a colour - which is what lets one catalog
8
+ * render the same under a light theme, a dark theme and a 16-colour ssh
9
+ * session. The role is not enough on its own, though: a token also has to be
10
+ * somewhere it can actually be drawn. `canvas` is a fill, `onAccent` is
11
+ * writing, `borderStrong` is a rule - and one union for all three is what let
12
+ * `fg: 'canvas'` and `border: 'onAccent'` compile, to be found by somebody
13
+ * looking at a frame.
14
+ *
15
+ * So each token is listed under the channels it may appear in, and `Style`
16
+ * takes the matching one. A token may appear in more than one - the tones are
17
+ * both a fill and a colour of their own - and a literal colour is the shared
18
+ * escape out of all three.
19
+ *
20
+ * A token missing from a list is a token that channel cannot use. Adding one
21
+ * is a decision about where it reads, not a convenience.
8
22
  */
9
- export type ColorToken =
10
- | 'canvas' | 'surface' | 'surfaceAlt' | 'overlay'
11
- | 'border' | 'borderStrong' | 'borderSubtle'
12
- | 'text' | 'muted' | 'subtle' | 'inverted'
23
+
24
+ /** Colours a cell's foreground may be. */
25
+ export type FgColorToken =
26
+ // A tone is a colour in its own right as well as a fill it provides.
13
27
  | 'accent' | 'primary' | 'secondary'
14
28
  | 'success' | 'warning' | 'danger' | 'info'
29
+ // The states are readable as text too: a disabled label, a focus-coloured
30
+ // border rendered as a caption.
31
+ | 'hover' | 'active' | 'selected' | 'focus' | 'disabled'
32
+ | 'text' | 'muted' | 'subtle' | 'inverted'
33
+ // A quiet rule is also the quietest text there is.
34
+ | 'borderSubtle'
35
+ // The caret is the one token that is honestly both: an underline caret is
36
+ // drawn in the foreground, and a block caret is the foreground swapped, so
37
+ // both halves are reached by naming it here.
38
+ | 'cursor'
15
39
  | 'onDefault' | 'onMuted'
16
40
  | 'onAccent' | 'onPrimary' | 'onSecondary'
17
41
  | 'onSuccess' | 'onWarning' | 'onDanger' | 'onInfo'
42
+ | 'onSelected' | 'onActive';
43
+
44
+ /** Colours a cell's background may be. */
45
+ export type BgColorToken =
46
+ | 'canvas' | 'surface' | 'surfaceAlt' | 'overlay'
18
47
  | 'hover' | 'active' | 'selected' | 'focus' | 'disabled'
19
- | 'scrim' | 'cursor' | 'shadow' | 'divider';
48
+ | 'accent' | 'primary' | 'secondary'
49
+ | 'success' | 'warning' | 'danger' | 'info'
50
+ // What is over everything, what the caret is, and what a shadow is.
51
+ | 'scrim' | 'cursor' | 'shadow';
52
+
53
+ /** Colours a rule may be. */
54
+ export type BorderColorToken =
55
+ | 'border' | 'borderStrong' | 'borderSubtle' | 'divider'
56
+ | 'accent' | 'primary' | 'secondary'
57
+ | 'success' | 'warning' | 'danger' | 'info'
58
+ | 'hover' | 'focus'
59
+ // A quiet rule and a quiet label are the same two colours often enough that
60
+ // a frame drawn in `muted` is a line somebody means, not a mistake. What is
61
+ // refused here is the loud end of the foreground list: a rule in `text` or
62
+ // in an `on*` token is writing used as structure.
63
+ | 'muted' | 'subtle';
64
+
65
+ /** Every token, for the places that take a colour without saying which. */
66
+ export type ColorToken = FgColorToken | BgColorToken | BorderColorToken;
20
67
 
21
- /** Anywhere a colour is accepted, a semantic token is accepted too. */
68
+ /** A foreground: an `FgColorToken`, or a literal colour. */
69
+ export type FgColor = FgColorToken | Color;
70
+ /** A background: a `BgColorToken`, or a literal colour. */
71
+ export type BgColor = BgColorToken | Color;
72
+ /** A rule: a `BorderColorToken`, or a literal colour. */
73
+ export type BorderColor = BorderColorToken | Color;
74
+
75
+ /** Anywhere a colour is accepted, any token and any literal are accepted too. */
22
76
  export type StyleColor = ColorToken | Color;
23
77
 
24
78
  export type Dimension = number | `${number}%` | 'auto';
@@ -86,17 +140,17 @@ export type BorderSides = {
86
140
 
87
141
  /** A colour per edge. Unnamed edges fall back to the border's `color`. */
88
142
  export type BorderColors = {
89
- top?: StyleColor;
90
- right?: StyleColor;
91
- bottom?: StyleColor;
92
- left?: StyleColor;
143
+ top?: BorderColor;
144
+ right?: BorderColor;
145
+ bottom?: BorderColor;
146
+ left?: BorderColor;
93
147
  };
94
148
 
95
149
  export type BorderSpec =
96
150
  | BorderStyle
97
151
  | {
98
152
  style?: BorderStyle;
99
- color?: StyleColor;
153
+ color?: BorderColor;
100
154
  /**
101
155
  * Per-edge colour, over `color`. A corner belongs to the edge that runs
102
156
  * through it - the top rule owns both top corners - because a cell holds
@@ -143,7 +197,7 @@ export interface Style {
143
197
  * but stays legible, rather than being replaced by a rectangle of nothing.
144
198
  * `true` uses the theme's `scrim` token; a number sets the strength.
145
199
  */
146
- scrim?: boolean | StyleColor;
200
+ scrim?: boolean | BgColor;
147
201
  scrimStrength?: number;
148
202
 
149
203
  // --- box ---
@@ -196,8 +250,8 @@ export interface Style {
196
250
  overflowY?: Overflow;
197
251
 
198
252
  // --- paint ---
199
- fg?: StyleColor;
200
- bg?: StyleColor;
253
+ fg?: FgColor;
254
+ bg?: BgColor;
201
255
  bold?: boolean;
202
256
  dim?: boolean;
203
257
  italic?: boolean;
@@ -214,16 +268,29 @@ export interface Style {
214
268
  fill?: string;
215
269
  }
216
270
 
217
- /** Styles selected by interaction state. Merged over the base in this order. */
271
+ /**
272
+ * Styles selected by interaction state. Merged over the base in this order.
273
+ *
274
+ * `selected` is "this is the current one"; `focus` is "and the keyboard is
275
+ * here", so a row that has both wears `focus` and a row that has only the
276
+ * first wears the dimmer `selected`. They are separate names because they
277
+ * were the same word once - `active` meant both the unfocused selection here
278
+ * and the pressed state in `InteractionState` - and a token that meant two
279
+ * things could be filled with either and looked wrong half the time.
280
+ */
218
281
  export interface StatefulStyle {
219
282
  base?: Style;
220
283
  focus?: Style;
221
284
  hover?: Style;
285
+ /** Pressed. Never a selection. */
222
286
  active?: Style;
223
287
  selected?: Style;
224
288
  disabled?: Style;
225
289
  }
226
290
 
291
+ /** The states, in the order the last one wins. The shared vocabulary. */
292
+ export type StateName = 'selected' | 'hover' | 'active' | 'focus' | 'disabled';
293
+
227
294
  export type StyleInput = Style | StatefulStyle | (Style | StatefulStyle | undefined | false)[];
228
295
 
229
296
  /** Global semantic variants, available to every component that opts in. */
@@ -77,6 +77,16 @@ export interface ThemeDefinition {
77
77
  id: string;
78
78
  name: string;
79
79
  appearance: 'light' | 'dark';
80
+ /**
81
+ * Whether this theme has any colour to give.
82
+ *
83
+ * A theme's palette is its own business, but a component can state a literal
84
+ * colour - an ink, a chart, a hand-picked hex - and that used to come through
85
+ * untouched, so `mono` painted a rainbow the moment a banner asked for one.
86
+ * Stated, every colour resolves to the terminal's own: tokens and literals
87
+ * alike, which is what a theme that says it has no colour has to mean.
88
+ */
89
+ monochrome?: boolean;
80
90
  /** Extend another registered theme; only the differences need stating. */
81
91
  extends?: string;
82
92
  colors: Partial<Record<ColorToken, Color>>;
@@ -105,7 +115,16 @@ export interface ThemeDefinition {
105
115
  */
106
116
  tableRules?: TableRules;
107
117
  density?: Density;
108
- /** Per-component style overrides, keyed by component name then variant. */
118
+ /**
119
+ * Per-component style overrides, keyed by component name then variant or
120
+ * state.
121
+ *
122
+ * A composite component has to name which of its boxes this is - a list row
123
+ * is a `box` node, and `List` is what a theme author knows it by - so the
124
+ * name is stated at the node with `styleAs` and the map is keyed by it.
125
+ * `base` is the component at rest; the rest are the variants it supports and
126
+ * the five states in `StateName`.
127
+ */
109
128
  components?: Record<string, Record<string, Style>>;
110
129
  /**
111
130
  * Colours for syntax scopes. Every scope has a default drawn from the
@@ -120,6 +139,8 @@ export interface ResolvedTheme {
120
139
  id: string;
121
140
  name: string;
122
141
  appearance: 'light' | 'dark';
142
+ /** Whether every colour this theme gives back is the terminal's own. */
143
+ monochrome: boolean;
123
144
  colors: Record<ColorToken, Color>;
124
145
  spacing: ThemeSpacing;
125
146
  glyphs: ThemeGlyphs;
@@ -131,11 +152,24 @@ export interface ResolvedTheme {
131
152
  components: Record<string, Record<string, Style>>;
132
153
  /** Every syntax scope, resolved to a colour. */
133
154
  syntax: Record<SyntaxScope, Color>;
134
- /** Resolve a token (or pass a literal colour through). */
155
+ /** Resolve a token (or pass a literal colour through, unless monochrome). */
135
156
  color(token: string): Color;
136
157
  borderChars(style?: BorderStyle): BorderChars;
137
158
  dividerChars(style?: DividerStyle): DividerChars;
138
- /** Component style for a name + variant list, merged in order. */
159
+ /**
160
+ * Component style for a name and a list of names to merge over it, in the
161
+ * order given - later names win.
162
+ *
163
+ * The names are the component's `variant`, `tone` and `size` followed by the
164
+ * interaction states that are true: `selected`, `hover`, `active` (pressed),
165
+ * `focus` and `disabled`. So `components.List.selected` is the fill on the
166
+ * current row, and `components.List.focused` is the brighter one on the
167
+ * current row while the list has the keyboard.
168
+ *
169
+ * The order is the same one `flattenStyleInput` merges a `style` prop in, so
170
+ * a state stated at the node and the same state stated by the theme resolve
171
+ * to one answer rather than to whichever happened to be asked second.
172
+ */
139
173
  styleFor(component: string, variants?: string[]): Style;
140
174
  }
141
175
 
package/src/util/text.ts CHANGED
@@ -283,6 +283,21 @@ export function expandTabs(text: string, tabWidth = 4): string {
283
283
 
284
284
  export type TruncateSide = 'end' | 'start' | 'middle';
285
285
 
286
+ /**
287
+ * Fit into `width` cells, marking the cut whether or not there was one.
288
+ *
289
+ * `truncate` marks only what it had to cut, which is right for a string that
290
+ * has to fit a cell. A paragraph stopped by the box rather than by its own
291
+ * length is the other case: its last visible row may fit exactly and still not
292
+ * be the end of the text, and it has to say so.
293
+ */
294
+ export function markCut(text: string, width: number, ellipsis = '…'): string {
295
+ if (width <= 0) return '';
296
+ const ew = stringWidth(ellipsis);
297
+ if (width <= ew) return sliceByWidth(ellipsis, width);
298
+ return sliceByWidth(text, width - ew).trimEnd() + ellipsis;
299
+ }
300
+
286
301
  /** Fit into `width` cells, marking the cut with `ellipsis`. */
287
302
  export function truncate(
288
303
  text: string,