@textui/widgets 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +17 -15
  2. package/dist/control/checkbox.d.ts +2 -0
  3. package/dist/control/checkbox.d.ts.map +1 -1
  4. package/dist/control/checkbox.js +2 -2
  5. package/dist/control/radio-group.d.ts +2 -0
  6. package/dist/control/radio-group.d.ts.map +1 -1
  7. package/dist/control/radio-group.js +2 -2
  8. package/dist/control/text-area.d.ts +15 -2
  9. package/dist/control/text-area.d.ts.map +1 -1
  10. package/dist/control/text-area.js +402 -22
  11. package/dist/control/text-input.d.ts.map +1 -1
  12. package/dist/control/text-input.js +40 -0
  13. package/dist/data/feed.d.ts +14 -0
  14. package/dist/data/feed.d.ts.map +1 -1
  15. package/dist/data/feed.js +28 -2
  16. package/dist/data/list.d.ts +60 -7
  17. package/dist/data/list.d.ts.map +1 -1
  18. package/dist/data/list.js +66 -10
  19. package/dist/data/markdown-view.d.ts.map +1 -1
  20. package/dist/data/markdown-view.js +49 -0
  21. package/dist/display/color-text.d.ts +158 -0
  22. package/dist/display/color-text.d.ts.map +1 -0
  23. package/dist/display/color-text.js +246 -0
  24. package/dist/display/index.d.ts +1 -0
  25. package/dist/display/index.d.ts.map +1 -1
  26. package/dist/display/index.js +3 -0
  27. package/dist/layout/divider.d.ts +6 -1
  28. package/dist/layout/divider.d.ts.map +1 -1
  29. package/dist/layout/divider.js +5 -5
  30. package/dist/layout/scroll-view.d.ts +10 -0
  31. package/dist/layout/scroll-view.d.ts.map +1 -1
  32. package/dist/layout/scroll-view.js +2 -2
  33. package/dist/navigation/menu.d.ts +24 -0
  34. package/dist/navigation/menu.d.ts.map +1 -1
  35. package/dist/navigation/menu.js +58 -15
  36. package/dist/overlay/command-palette.d.ts +32 -1
  37. package/dist/overlay/command-palette.d.ts.map +1 -1
  38. package/dist/overlay/command-palette.js +84 -10
  39. package/dist/shells/workbench-shell.d.ts.map +1 -1
  40. package/dist/shells/workbench-shell.js +16 -2
  41. package/package.json +8 -8
  42. package/src/control/checkbox.ts +4 -2
  43. package/src/control/radio-group.ts +4 -2
  44. package/src/control/text-area.ts +394 -31
  45. package/src/control/text-input.ts +38 -1
  46. package/src/data/feed.ts +44 -1
  47. package/src/data/list.ts +125 -18
  48. package/src/data/markdown-view.ts +56 -0
  49. package/src/display/color-text.ts +386 -0
  50. package/src/display/index.ts +3 -0
  51. package/src/layout/divider.ts +15 -7
  52. package/src/layout/scroll-view.ts +12 -2
  53. package/src/navigation/menu.ts +89 -14
  54. package/src/overlay/command-palette.ts +125 -11
  55. package/src/shells/workbench-shell.ts +16 -3
@@ -0,0 +1,386 @@
1
+ import type {
2
+ CellStyle, Color, InteractionState, PaintSurface,
3
+ RenderContext, StyleColor, TextProps, TextWrap,
4
+ } from '@textui/core';
5
+ import {
6
+ attrsFromStyle, defineComponent, flattenStyleInput, graphemeWidth, graphemes, h,
7
+ mergeStyles, mix, packColor, resolveColor, sanitize, stringWidth, styleFromProps,
8
+ truncate, truncateSideOf, unpackColor, useMeasure, wrapModeOf, wrapText,
9
+ } from '@textui/core';
10
+
11
+ /**
12
+ * One cell, as the ink sees it.
13
+ *
14
+ * Both a column and an index, because they are not the same number and which
15
+ * one is wanted depends on the question. `col` is where the cell lands - a
16
+ * wide character advances it by two - so a gradient stays vertical over CJK
17
+ * and emoji. `index` counts graphemes, which is what "the fourth letter"
18
+ * means. Colouring by `index` and painting at `col` is the bug this pair
19
+ * exists to prevent.
20
+ */
21
+ export interface InkCell {
22
+ /** The grapheme cluster about to be painted. */
23
+ char: string;
24
+ /** Column within the line, in cells. */
25
+ col: number;
26
+ /** Line index, after wrapping. */
27
+ line: number;
28
+ /** Grapheme index within the line. */
29
+ index: number;
30
+ /** Grapheme index within the whole block. */
31
+ offset: number;
32
+ /** Cells in this line. */
33
+ width: number;
34
+ /** Lines in the block. */
35
+ height: number;
36
+ /** Cells in the widest line of the block. */
37
+ blockWidth: number;
38
+ }
39
+
40
+ /**
41
+ * An ink written as code. Returns a colour, a whole cell style, or nothing.
42
+ *
43
+ * Nothing means "leave this cell to the component's own style", which is what
44
+ * makes an ink that colours one thing - the vowels, the column under the
45
+ * cursor - a two-line function rather than an exhaustive one.
46
+ */
47
+ export type InkFn = (cell: InkCell, ctx: RenderContext) => StyleColor | CellStyle | undefined;
48
+
49
+ /** A ramp between colour stops. */
50
+ export interface GradientInk {
51
+ /** Two or more stops. One stop is a flat colour, none is nothing. */
52
+ gradient: StyleColor[];
53
+ /** Across, down, or corner to corner. */
54
+ axis?: 'x' | 'y' | 'xy';
55
+ /**
56
+ * What the ramp is measured against.
57
+ *
58
+ * `block` is the widest line, so every line of a banner shares one ramp and
59
+ * the colours line up down the block. `line` restarts the ramp on each line,
60
+ * which is what a paragraph of ragged lines wants - under `block` a short
61
+ * line would stop halfway through the ramp and look unfinished.
62
+ */
63
+ per?: 'block' | 'line';
64
+ }
65
+
66
+ /**
67
+ * What advances the colour.
68
+ *
69
+ * `cell` counts columns, so bands stay vertical across the lines of a block -
70
+ * the reason it is the default. `grapheme` counts every cluster including
71
+ * spaces, `letter` counts only the ones that print, and `word` and `line`
72
+ * are what they say.
73
+ */
74
+ export type InkUnit = 'cell' | 'grapheme' | 'letter' | 'word' | 'line';
75
+
76
+ /** A palette walked in order and repeated. */
77
+ export interface CycleInk {
78
+ cycle: StyleColor[];
79
+ /**
80
+ * How much of the text each colour takes.
81
+ *
82
+ * A number is a fixed run. An array is a repeating pattern of runs, so
83
+ * `[4, 3]` gives four cells of the first colour, three of the second, four
84
+ * of the third, and keeps going in fours and threes.
85
+ */
86
+ every?: number | number[];
87
+ unit?: InkUnit;
88
+ /**
89
+ * Whether the count carries across a line break. Off by default: a run that
90
+ * restarts on every line keeps the bands of a block aligned, and a run that
91
+ * carries over puts them on a diagonal.
92
+ */
93
+ continuous?: boolean;
94
+ }
95
+
96
+ /**
97
+ * How a `ColorText` is coloured.
98
+ *
99
+ * An array is the short spelling of one colour per line. The two object forms
100
+ * are data, so a screen written as JSON can carry them; the function form
101
+ * cannot be serialized and is the escape hatch - the same trade `canvas`
102
+ * makes with `draw`.
103
+ */
104
+ export type Ink = StyleColor[] | GradientInk | CycleInk | InkFn;
105
+
106
+ /**
107
+ * Everything a `text` takes, plus the ink.
108
+ *
109
+ * The same props on purpose: `wrap`, `truncate`, `textAlign` and the style
110
+ * keys all mean here what they mean there, so swapping one for the other is a
111
+ * change of colour and nothing else.
112
+ */
113
+ export interface ColorTextProps extends TextProps {
114
+ /** Left unset, this is an ordinary block of text. */
115
+ ink?: Ink;
116
+ /**
117
+ * Align the block as one thing, rather than each line on its own.
118
+ *
119
+ * `textAlign` centres every line over its own middle, which is right for
120
+ * prose and shears a picture: five rows of block letters do not have equal
121
+ * widths once the trailing spaces are gone, so each row lands somewhere
122
+ * slightly different and the letters lean. Under this, the whole block is
123
+ * placed once and the lines keep their offsets from each other.
124
+ *
125
+ * Off by default, because that is what `text` does and the two are supposed
126
+ * to mean the same thing by the same prop.
127
+ */
128
+ alignBlock?: boolean;
129
+ }
130
+
131
+ /**
132
+ * Multiline text coloured cell by cell.
133
+ *
134
+ * A `text` takes one colour for the whole run, which is the right answer for
135
+ * nearly everything and no answer at all for the cases where the colour *is*
136
+ * the content - a banner, a ramp across a title, a palette walked down a
137
+ * block of ascii art. This is that case, and only that case: it is still just
138
+ * text, and the alternative to it is a `text` node.
139
+ *
140
+ * It paints on a `canvas`, and the price of the escape hatch is that inherited
141
+ * colour stops here. A cell the ink declines takes this component's own `fg`
142
+ * rather than the one a parent row would have handed a `text`, so a
143
+ * `ColorText` inside something that recolours its children when selected has
144
+ * to be told about it. Everything an ink does paint is unaffected.
145
+ *
146
+ * ```tsx
147
+ * <ColorText ink={{ gradient: ['cyan', 'magenta'] }}>{banner}</ColorText>
148
+ * <ColorText ink={['danger', 'warning', 'success']}>{lines}</ColorText>
149
+ * <ColorText ink={{ cycle: palette, every: [4, 3] }}>{title}</ColorText>
150
+ * <ColorText ink={(cell) => (cell.line === cell.col ? 'accent' : undefined)}>{grid}</ColorText>
151
+ * ```
152
+ *
153
+ * The colour is decoration and never the message: a 16-colour session flattens
154
+ * a three-stop ramp into a couple of bands, and a piped log loses all of it.
155
+ */
156
+ export const ColorText = defineComponent<ColorTextProps>('ColorText', (props) => {
157
+ const { content, children, ink, ellipsis, alignBlock, ...rest } = props;
158
+ const source = sanitize(textOf(content, children));
159
+ const wrap = (props.wrap ?? 'none') as TextWrap;
160
+
161
+ // The width layout settled on last frame, for the height this frame asks
162
+ // for. Painting uses the surface's own width instead - that one is current -
163
+ // so the lag here costs a line count and never a wrong line break.
164
+ const measured = useMeasure().width;
165
+ const natural = source.split('\n').reduce((w, line) => Math.max(w, stringWidth(line)), 0);
166
+ const height = linesOf(source, wrap, measured > 0 ? measured : natural).length;
167
+
168
+ // A block that does not wrap is as wide as its widest line and says so -
169
+ // that is what a banner is. One that wraps or truncates asks for nothing and
170
+ // takes what it is given, because a paragraph that reported the width of its
171
+ // unbroken text would push every sibling off the row to get it.
172
+ const width = wrap === 'none' ? natural : 0;
173
+
174
+ const draw = (surface: PaintSurface, ctx: RenderContext): void => {
175
+ const { width: area, height: rows } = surface.rect;
176
+ if (area <= 0 || rows <= 0 || source === '') return;
177
+
178
+ // The component's own style, resolved for the state it is in. Not the
179
+ // inherited one - a canvas is not told what it was nested in.
180
+ const own = mergeStyles(
181
+ styleFromProps(props as Record<string, unknown>),
182
+ flattenStyleInput(props.style, stateOf(ctx)),
183
+ );
184
+ const rest: CellStyle = {
185
+ fg: resolveColor(own.fg, ctx.theme, 'default'),
186
+ attrs: attrsFromStyle(own),
187
+ link: typeof props.link === 'string' ? props.link : undefined,
188
+ };
189
+
190
+ const cut = props.truncate ?? truncateSideOf(wrap) ?? 'end';
191
+ const dots = ellipsis ?? ctx.theme.glyphs.ellipsis;
192
+ const lines = linesOf(source, wrap, area).map((line) =>
193
+ (stringWidth(line) > area && cut !== false ? truncate(line, area, dots, cut) : line));
194
+
195
+ // After truncating, not before: the width the block is placed by is the
196
+ // width it is going to occupy.
197
+ const blockWidth = lines.reduce((w, line) => Math.max(w, stringWidth(line)), 0);
198
+ const paint = painterOf(ink, ctx);
199
+ const align = own.textAlign ?? 'left';
200
+ const origin = alignBlock ? indentFor(align, area, blockWidth) : undefined;
201
+
202
+ let offset = 0;
203
+ for (let y = 0; y < lines.length && y < rows; y++) {
204
+ const line = lines[y] as string;
205
+ const lineWidth = stringWidth(line);
206
+ const indent = origin ?? indentFor(align, area, lineWidth);
207
+
208
+ let col = 0;
209
+ let index = 0;
210
+ for (const char of graphemes(line)) {
211
+ const advance = graphemeWidth(char);
212
+ if (advance === 0) continue;
213
+ const style = paint({
214
+ char, col, line: y, index, offset, width: lineWidth, height: lines.length, blockWidth,
215
+ });
216
+ // A space with nothing but a foreground has nothing to show, and
217
+ // painting it would wipe out whatever the box behind it drew there.
218
+ if (char !== ' ' || style?.bg !== undefined || rest.bg !== undefined) {
219
+ surface.put(indent + col, y, char, style ? { ...rest, ...style } : rest);
220
+ }
221
+ col += advance;
222
+ index++;
223
+ offset++;
224
+ }
225
+ }
226
+ };
227
+
228
+ return h('canvas', { draw, intrinsic: { width, height }, ...rest });
229
+ });
230
+
231
+ // ------------------------------------------------------------------- inks
232
+
233
+ /**
234
+ * A colour a fraction of the way along a set of stops.
235
+ *
236
+ * Exported because an ink written by hand wants it and the alternative is
237
+ * unpacking colours in application code. `t` outside 0..1 is clamped rather
238
+ * than extrapolated - a ramp has ends.
239
+ */
240
+ export function gradientAt(stops: Color[], t: number): Color {
241
+ if (stops.length === 0) return 'default';
242
+ if (stops.length === 1) return stops[0] as Color;
243
+ const at = Math.min(1, Math.max(0, t)) * (stops.length - 1);
244
+ const i = Math.min(stops.length - 2, Math.floor(at));
245
+ return blend(stops[i] as Color, stops[i + 1] as Color, at - i);
246
+ }
247
+
248
+ /** Two colours mixed. `t` of 0 is `a`, 1 is `b`. Tokens must be resolved first. */
249
+ export function blend(a: Color, b: Color, t: number): Color {
250
+ return unpackColor(mix(packColor(a), packColor(b), Math.min(1, Math.max(0, t))));
251
+ }
252
+
253
+ /**
254
+ * An ink in any of its spellings, as the one function paint uses.
255
+ *
256
+ * Exported so a component that paints its own cells - a chart, a viewer with a
257
+ * heat column - can take an `Ink` and mean the same thing by it.
258
+ */
259
+ export function painterOf(
260
+ ink: Ink | undefined,
261
+ ctx: RenderContext,
262
+ ): (cell: InkCell) => CellStyle | undefined {
263
+ if (ink === undefined) return () => undefined;
264
+
265
+ if (typeof ink === 'function') {
266
+ return (cell) => styleOf(ink(cell, ctx), ctx);
267
+ }
268
+
269
+ if (Array.isArray(ink)) {
270
+ return painterOf({ cycle: ink, unit: 'line' }, ctx);
271
+ }
272
+
273
+ if ('gradient' in ink) {
274
+ const stops = ink.gradient.map((c) => resolveColor(c, ctx.theme, 'default'));
275
+ const axis = ink.axis ?? 'x';
276
+ const per = ink.per ?? 'block';
277
+ return (cell) => {
278
+ const span = per === 'line' ? cell.width : cell.blockWidth;
279
+ // A single column or a single line has no distance to ramp over, and
280
+ // dividing by zero would put every cell at the far end of the ramp.
281
+ const x = span > 1 ? cell.col / (span - 1) : 0;
282
+ const y = cell.height > 1 ? cell.line / (cell.height - 1) : 0;
283
+ const t = axis === 'x' ? x : axis === 'y' ? y : (x + y) / 2;
284
+ return { fg: gradientAt(stops, t) };
285
+ };
286
+ }
287
+
288
+ const colors = ink.cycle.map((c) => resolveColor(c, ctx.theme, 'default'));
289
+ if (colors.length === 0) return () => undefined;
290
+ const unit = ink.unit ?? 'cell';
291
+ const runs = (Array.isArray(ink.every) ? ink.every : [ink.every ?? 1])
292
+ .map((n) => Math.max(1, Math.floor(n)));
293
+ const continuous = ink.continuous ?? false;
294
+
295
+ // `letter` and `word` are counted rather than derived, because both depend
296
+ // on what came before them on the line and neither can be recovered from a
297
+ // column number. Kept per line so the run restarts where the line does.
298
+ let letters = 0;
299
+ let words = 0;
300
+ let wasBlank = true;
301
+ let at = -1;
302
+
303
+ return (cell) => {
304
+ if (cell.line !== at) {
305
+ at = cell.line;
306
+ if (!continuous) { letters = 0; words = 0; }
307
+ wasBlank = true;
308
+ }
309
+ const blank = cell.char.trim() === '';
310
+ if (!blank && wasBlank) words++;
311
+ if (!blank) letters++;
312
+ wasBlank = blank;
313
+
314
+ const n = unit === 'cell' ? (continuous ? cell.offset : cell.col)
315
+ : unit === 'grapheme' ? (continuous ? cell.offset : cell.index)
316
+ : unit === 'letter' ? Math.max(0, letters - 1)
317
+ : unit === 'word' ? Math.max(0, words - 1)
318
+ : cell.line;
319
+ return { fg: colors[runIndex(n, runs) % colors.length] as Color };
320
+ };
321
+ }
322
+
323
+ /** Which run of a repeating pattern a count falls in. `[4, 3]` over 8 gives 2. */
324
+ function runIndex(n: number, runs: number[]): number {
325
+ const total = runs.reduce((a, b) => a + b, 0);
326
+ const cycles = Math.floor(n / total);
327
+ let rest = n % total;
328
+ let i = 0;
329
+ while (rest >= (runs[i] as number)) { rest -= runs[i] as number; i++; }
330
+ return cycles * runs.length + i;
331
+ }
332
+
333
+ /** A colour, a style, or nothing - as a style, with its tokens resolved. */
334
+ function styleOf(
335
+ value: StyleColor | CellStyle | undefined,
336
+ ctx: RenderContext,
337
+ ): CellStyle | undefined {
338
+ if (value === undefined) return undefined;
339
+ if (typeof value === 'string') return { fg: resolveColor(value, ctx.theme, 'default') };
340
+ if ('rgb' in value || 'palette' in value) return { fg: value as Color };
341
+ const style = value as CellStyle;
342
+ return {
343
+ ...style,
344
+ fg: style.fg === undefined ? undefined : resolveColor(style.fg, ctx.theme, 'default'),
345
+ bg: style.bg === undefined ? undefined : resolveColor(style.bg, ctx.theme, 'default'),
346
+ };
347
+ }
348
+
349
+ // ------------------------------------------------------------------ text
350
+
351
+ function textOf(content: unknown, children: unknown): string {
352
+ if (typeof content === 'string') return content;
353
+ if (typeof content === 'number') return String(content);
354
+ if (typeof children === 'string') return children;
355
+ if (typeof children === 'number') return String(children);
356
+ if (Array.isArray(children)) {
357
+ return children.filter((c) => typeof c === 'string' || typeof c === 'number').join('');
358
+ }
359
+ return '';
360
+ }
361
+
362
+ /** Where a line of `width` starts, in a box of `area`. */
363
+ function indentFor(align: 'left' | 'center' | 'right', area: number, width: number): number {
364
+ if (align === 'center') return Math.max(0, Math.floor((area - width) / 2));
365
+ if (align === 'right') return Math.max(0, area - width);
366
+ return 0;
367
+ }
368
+
369
+ /** The same line-breaking `text` does, so the two agree about where a line ends. */
370
+ function linesOf(text: string, wrap: TextWrap, width: number): string[] {
371
+ if (text === '') return [];
372
+ // The truncating modes are one line by definition; a newline inside one has
373
+ // nowhere to go, so it becomes a space rather than silently taking the rest
374
+ // of the text with it.
375
+ if (truncateSideOf(wrap) !== undefined) return [text.replace(/\n/g, ' ')];
376
+ const mode = wrapModeOf(wrap);
377
+ if (mode === 'none' || width <= 0) return text.split('\n');
378
+ return wrapText(text, width, mode);
379
+ }
380
+
381
+ function stateOf(ctx: RenderContext): InteractionState {
382
+ return {
383
+ focused: ctx.focused, hovered: ctx.hovered, active: ctx.active,
384
+ selected: ctx.selected, disabled: ctx.disabled,
385
+ };
386
+ }
@@ -2,6 +2,7 @@ import type { ComponentDefinition } from '@textui/core';
2
2
  import { Alert } from './alert.js';
3
3
  import { Badge } from './badge.js';
4
4
  import { Card } from './card.js';
5
+ import { ColorText } from './color-text.js';
5
6
  import { EmptyState } from './empty-state.js';
6
7
  import { ErrorState } from './error-state.js';
7
8
  import { Heading } from './heading.js';
@@ -25,6 +26,7 @@ import { Timeline } from './timeline.js';
25
26
  export * from './alert.js';
26
27
  export * from './badge.js';
27
28
  export * from './card.js';
29
+ export * from './color-text.js';
28
30
  export * from './empty-state.js';
29
31
  export * from './error-state.js';
30
32
  export * from './heading.js';
@@ -47,6 +49,7 @@ export const DISPLAY_COMPONENTS: ComponentDefinition[] = [
47
49
  { component: 'KeyValue', category: 'data', renderer: { kind: 'function', render: KeyValue }, description: 'Aligned label/value pairs.' },
48
50
  { component: 'Progress', category: 'feedback', renderer: { kind: 'function', render: Progress }, role: 'progressbar', description: 'Determinate or indeterminate bar, sub-cell resolution.' },
49
51
  { component: 'Spinner', category: 'feedback', renderer: { kind: 'function', render: Spinner }, role: 'status', description: 'Animated activity indicator.' },
52
+ { component: 'ColorText', category: 'display', renderer: { kind: 'function', render: ColorText }, description: 'Multiline text coloured cell by cell - a ramp, a palette per line, or a function.' },
50
53
  { component: 'Marquee', category: 'display', renderer: { kind: 'function', render: Marquee }, role: 'marquee', description: 'Text too long for its box, read by sliding it while it has the cursor.' },
51
54
  { component: 'Skeleton', category: 'feedback', renderer: { kind: 'function', render: Skeleton }, description: 'Loading placeholder.' },
52
55
  { component: 'EmptyState', category: 'feedback', renderer: { kind: 'function', render: EmptyState }, description: 'Nothing here, and what to do about it.' },
@@ -1,4 +1,4 @@
1
- import type { BoxProps } from '@textui/core';
1
+ import type { BoxProps, DividerStyle } from '@textui/core';
2
2
  import { defineComponent, h, useTheme } from '@textui/core';
3
3
 
4
4
  export interface DividerProps extends Omit<BoxProps, 'direction'> {
@@ -7,25 +7,33 @@ export interface DividerProps extends Omit<BoxProps, 'direction'> {
7
7
  /** Text set into the rule. */
8
8
  label?: string;
9
9
  labelAlign?: 'left' | 'center' | 'right';
10
+ /**
11
+ * The rule style. The theme's own is the default, so a borderless theme
12
+ * still gets the line it asked for.
13
+ */
14
+ rule?: DividerStyle;
10
15
  char?: string;
11
16
  }
12
17
 
13
18
  export const Divider = defineComponent<DividerProps>('Divider', (props) => {
14
19
  const theme = useTheme();
15
- const { direction = 'horizontal', label, labelAlign = 'left', char, ...rest } = props;
16
- const chars = theme.borderChars();
20
+ const {
21
+ direction = 'horizontal', label, labelAlign = 'left',
22
+ char, rule: ruleStyle, fg = 'divider', ...rest
23
+ } = props;
24
+ const rule = theme.dividerChars(ruleStyle);
17
25
 
18
26
  if (direction === 'vertical') {
19
- return h('box', { role: 'separator', width: 1, fill: char ?? chars.left, fg: 'border', ...rest });
27
+ return h('box', { role: 'separator', width: 1, fill: char ?? rule.vertical, fg, ...rest });
20
28
  }
21
29
 
22
30
  if (!label) {
23
- return h('box', { role: 'separator', height: 1, fill: char ?? chars.top, fg: 'border', ...rest });
31
+ return h('box', { role: 'separator', height: 1, fill: char ?? rule.horizontal, fg, ...rest });
24
32
  }
25
33
 
26
34
  return h('box', { direction: 'row', gap: 1, height: 1, ...rest },
27
- labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null,
35
+ labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null,
28
36
  h('text', { content: label, fg: 'muted' }),
29
- labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null,
37
+ labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null,
30
38
  );
31
39
  });
@@ -27,6 +27,16 @@ export interface ScrollViewProps extends BoxProps {
27
27
  */
28
28
  focusable?: boolean;
29
29
  autoFocus?: boolean;
30
+ /**
31
+ * A stable focus id, so a command - or the screen that owns this - can put
32
+ * the reader here by name.
33
+ *
34
+ * Without one the id comes from the instance, which nothing outside the
35
+ * render can know: "scroll the preview" has nothing to name and the key that
36
+ * would do it cannot be written. Every other focusable control takes one;
37
+ * this was the exception, and there was no reason for it.
38
+ */
39
+ focusId?: string;
30
40
  }
31
41
 
32
42
  /**
@@ -38,10 +48,10 @@ export interface ScrollViewProps extends BoxProps {
38
48
  */
39
49
  export const ScrollView = defineComponent<ScrollViewProps>('ScrollView', (props) => {
40
50
  const {
41
- offset, onScroll, scrollbar = true, focusable = true, autoFocus, id,
51
+ offset, onScroll, scrollbar = true, focusable = true, autoFocus, focusId, id,
42
52
  children, ...rest
43
53
  } = props;
44
- const focus = useFocus({ disabled: !focusable, autoFocus });
54
+ const focus = useFocus({ ...(focusId ? { id: focusId } : {}), disabled: !focusable, autoFocus });
45
55
  const [internal, setInternal] = useState(0);
46
56
  const top = offset ?? internal;
47
57
  // How far down this can go before the last line leaves the bottom. Written
@@ -1,5 +1,5 @@
1
1
  import type { BoxProps, SemanticVariant } from '@textui/core';
2
- import { defineComponent, h, useFocus, useInput, useState, useTheme } from '@textui/core';
2
+ import { defineComponent, h, stringWidth, useFocus, useInput, useState, useTheme } from '@textui/core';
3
3
  import { Marquee } from '../display/index.js';
4
4
  import { TONE } from '../tone.js';
5
5
 
@@ -13,6 +13,18 @@ export interface MenuItem {
13
13
  disabled?: boolean;
14
14
  tone?: SemanticVariant;
15
15
  separatorBefore?: boolean;
16
+ /**
17
+ * A heading on the line above this row, naming the group it starts.
18
+ *
19
+ * The name of a group belongs to the group, so it is said once at the top
20
+ * of it rather than repeated on every row - a column reading "Screens,
21
+ * Screens, Screens" spends the width that the rows themselves need, and
22
+ * still does not say where one group ends.
23
+ *
24
+ * It takes the line a `separatorBefore` would have used rather than adding
25
+ * one, so a grouped menu is the same height either way.
26
+ */
27
+ sectionBefore?: string;
16
28
  /**
17
29
  * A switch, and whether it is on. Absent means the row is not a switch, so
18
30
  * a menu of ordinary commands keeps its left edge rather than indenting
@@ -29,6 +41,18 @@ export interface MenuProps extends BoxProps {
29
41
  visibleRows?: number;
30
42
  activeId?: string;
31
43
  autoFocus?: boolean;
44
+ /**
45
+ * Where a row's description goes.
46
+ *
47
+ * `inline` right-aligns it on the row, sharing the width with the label -
48
+ * which is the right shape for a word or two of state. `below` gives it a
49
+ * line of its own under the label, indented to it, which is the only shape
50
+ * that fits a sentence: inline, a list of modes whose whole difference is
51
+ * the sentence under each shows the same truncated half of every one.
52
+ *
53
+ * `below` makes every row two lines, so `visibleRows` buys half as much.
54
+ */
55
+ descriptions?: 'inline' | 'below';
32
56
  /**
33
57
  * Take focus and handle keys. Off when something else drives the selection -
34
58
  * a command palette, where typing belongs to the search field and the list
@@ -40,7 +64,8 @@ export interface MenuProps extends BoxProps {
40
64
  export const Menu = defineComponent<MenuProps>('Menu', (props) => {
41
65
  const theme = useTheme();
42
66
  const {
43
- items, onSelect, visibleRows, activeId, autoFocus, interactive = true, ...rest
67
+ items, onSelect, visibleRows, activeId, autoFocus, interactive = true,
68
+ descriptions = 'inline', ...rest
44
69
  } = props;
45
70
  const focus = useFocus({ autoFocus, disabled: !interactive });
46
71
  const selectable = items.filter((i) => !i.disabled);
@@ -82,17 +107,9 @@ export const Menu = defineComponent<MenuProps>('Menu', (props) => {
82
107
  return h('box', { id: focus.id, role: 'menu', direction: 'column', ...rest },
83
108
  ...window.flatMap((item, i) => {
84
109
  const active = start + i === highlight;
85
- const row = h('box', {
86
- key: item.id,
87
- role: 'menuitem',
88
- label: item.label,
89
- selected: active,
90
- direction: 'row',
91
- gap: 1,
92
- bg: active ? 'selected' : undefined,
93
- fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined,
94
- onClick: () => { if (!item.disabled) onSelect?.(item.id, item); },
95
- },
110
+ const below = descriptions === 'below' && item.description !== undefined;
111
+
112
+ const head = h('box', { direction: 'row', gap: 1 },
96
113
  h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 }),
97
114
  switches
98
115
  ? h('text', { content: item.checked === true ? theme.glyphs.check : ' ', shrink: 0 })
@@ -108,7 +125,7 @@ export const Menu = defineComponent<MenuProps>('Menu', (props) => {
108
125
  // The description yields first, and by a lot. It is the elaboration;
109
126
  // the label is the thing being chosen, and a row reading "Accept ed…"
110
127
  // beside a full sentence has given up the wrong half.
111
- item.description
128
+ item.description && !below
112
129
  ? h(Marquee, {
113
130
  content: item.description,
114
131
  active,
@@ -123,6 +140,49 @@ export const Menu = defineComponent<MenuProps>('Menu', (props) => {
123
140
  item.children ? h('text', { content: theme.glyphs.chevronRight }) : null,
124
141
  );
125
142
 
143
+ const row = h('box', {
144
+ key: item.id,
145
+ role: 'menuitem',
146
+ label: item.label,
147
+ selected: active,
148
+ direction: 'column',
149
+ // One background over both lines: a highlight that stopped after the
150
+ // label would split the row it is highlighting in two.
151
+ bg: active ? 'selected' : undefined,
152
+ fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined,
153
+ onClick: () => { if (!item.disabled) onSelect?.(item.id, item); },
154
+ },
155
+ head,
156
+ // Under the label rather than under the cursor: the sentence is about
157
+ // the thing being chosen, so it starts where that thing starts.
158
+ below
159
+ ? h('box', { direction: 'row' },
160
+ h('text', { content: ' '.repeat(leading(item, switches)), shrink: 0 }),
161
+ // A `Marquee`, like the inline one: a sentence too long for the
162
+ // panel is still readable on the row the cursor is on, by sliding
163
+ // it. Truncated and still is right for the rows being scanned past
164
+ // and useless for the one that has been stopped on.
165
+ h(Marquee, {
166
+ content: item.description as string,
167
+ active,
168
+ fg: active ? 'inverted' : 'muted',
169
+ flex: 1,
170
+ }))
171
+ : null,
172
+ );
173
+
174
+ if (item.sectionBefore) {
175
+ return [
176
+ h('box', { key: `${item.id}-sec`, role: 'heading', direction: 'row', gap: 1 },
177
+ // The same leading columns the rows have, so a heading sits over
178
+ // the labels it names rather than over the cursor's gutter.
179
+ h('text', { content: ' ', shrink: 0 }),
180
+ switches ? h('text', { content: ' ', shrink: 0 }) : null,
181
+ h('text', { content: item.sectionBefore, bold: true, fg: 'muted', truncate: 'end' })),
182
+ row,
183
+ ];
184
+ }
185
+
126
186
  return item.separatorBefore
127
187
  ? [h('box', { key: `${item.id}-sep`, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }), row]
128
188
  : [row];
@@ -132,3 +192,18 @@ export const Menu = defineComponent<MenuProps>('Menu', (props) => {
132
192
  : null,
133
193
  );
134
194
  });
195
+
196
+ /**
197
+ * The columns before a row's label, so a second line can start under it.
198
+ *
199
+ * The cursor's column and the switch's are the same on every row - that is
200
+ * what keeps a menu's left edge straight - but the icon is per-row and may be
201
+ * two cells wide, so it has to be measured rather than assumed.
202
+ */
203
+ function leading(item: MenuItem, switches: boolean): number {
204
+ // The marker, plus the gap after it.
205
+ let width = 2;
206
+ if (switches) width += 2;
207
+ if (item.icon) width += stringWidth(item.icon) + 1;
208
+ return width;
209
+ }