@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.
@@ -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,