@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.
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +8 -1
- package/dist/core/focus.d.ts.map +1 -1
- package/dist/core/focus.js +33 -3
- package/dist/core/i18n.d.ts +6 -3
- package/dist/core/i18n.d.ts.map +1 -1
- package/dist/core/i18n.js +10 -6
- package/dist/jsx/intrinsics.d.ts +23 -0
- package/dist/jsx/intrinsics.d.ts.map +1 -1
- package/dist/runtime/hooks.d.ts.map +1 -1
- package/dist/runtime/hooks.js +15 -0
- package/dist/runtime/paint.js +12 -2
- package/dist/runtime/style.d.ts +17 -7
- package/dist/runtime/style.d.ts.map +1 -1
- package/dist/runtime/style.js +66 -6
- package/dist/themes/builtin.d.ts +0 -9
- package/dist/themes/builtin.d.ts.map +1 -1
- package/dist/themes/builtin.js +101 -4
- package/dist/themes/registry.d.ts.map +1 -1
- package/dist/themes/registry.js +26 -0
- package/dist/types/focus.d.ts +11 -0
- package/dist/types/focus.d.ts.map +1 -1
- package/dist/types/i18n.d.ts +14 -3
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/style.d.ts +53 -14
- package/dist/types/style.d.ts.map +1 -1
- package/dist/types/theme.d.ts +37 -3
- package/dist/types/theme.d.ts.map +1 -1
- package/dist/util/text.d.ts +9 -0
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +16 -0
- package/package.json +1 -1
- package/src/app/app.ts +8 -1
- package/src/core/focus.ts +30 -3
- package/src/core/i18n.ts +10 -6
- package/src/jsx/intrinsics.ts +25 -0
- package/src/runtime/hooks.ts +15 -0
- package/src/runtime/paint.ts +11 -2
- package/src/runtime/style.ts +73 -9
- package/src/themes/builtin.ts +105 -4
- package/src/themes/registry.ts +23 -0
- package/src/types/focus.ts +8 -0
- package/src/types/i18n.ts +14 -3
- package/src/types/style.ts +85 -18
- package/src/types/theme.ts +37 -3
- 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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
61
|
-
|
|
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
|
|
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
|
-
(
|
|
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 {
|
package/src/jsx/intrinsics.ts
CHANGED
|
@@ -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. */
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -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?.();
|
package/src/runtime/paint.ts
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/src/runtime/style.ts
CHANGED
|
@@ -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
|
|
98
|
-
if (typeof props.variant === 'string')
|
|
99
|
-
if (typeof props.tone === 'string')
|
|
100
|
-
if (typeof props.size === 'string')
|
|
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(
|
|
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
|
-
/**
|
|
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:
|
|
211
|
+
color: BorderColor | undefined;
|
|
148
212
|
/** Per-edge overrides. Undefined here means "use `color`". */
|
|
149
|
-
colors: { top?:
|
|
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;
|
package/src/themes/builtin.ts
CHANGED
|
@@ -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: '#
|
|
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: '
|
|
186
|
-
|
|
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
|
/**
|
package/src/themes/registry.ts
CHANGED
|
@@ -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/focus.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
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
|
}
|