@textui/widgets 0.1.0 → 0.3.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 (59) 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 +88 -4
  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/data/shared.d.ts +18 -1
  22. package/dist/data/shared.d.ts.map +1 -1
  23. package/dist/data/shared.js +12 -2
  24. package/dist/display/color-text.d.ts +158 -0
  25. package/dist/display/color-text.d.ts.map +1 -0
  26. package/dist/display/color-text.js +246 -0
  27. package/dist/display/index.d.ts +1 -0
  28. package/dist/display/index.d.ts.map +1 -1
  29. package/dist/display/index.js +3 -0
  30. package/dist/layout/divider.d.ts +6 -1
  31. package/dist/layout/divider.d.ts.map +1 -1
  32. package/dist/layout/divider.js +5 -5
  33. package/dist/layout/scroll-view.d.ts +10 -0
  34. package/dist/layout/scroll-view.d.ts.map +1 -1
  35. package/dist/layout/scroll-view.js +2 -2
  36. package/dist/navigation/menu.d.ts +24 -0
  37. package/dist/navigation/menu.d.ts.map +1 -1
  38. package/dist/navigation/menu.js +58 -15
  39. package/dist/overlay/command-palette.d.ts +32 -1
  40. package/dist/overlay/command-palette.d.ts.map +1 -1
  41. package/dist/overlay/command-palette.js +84 -10
  42. package/dist/shells/workbench-shell.d.ts.map +1 -1
  43. package/dist/shells/workbench-shell.js +22 -2
  44. package/package.json +8 -8
  45. package/src/control/checkbox.ts +4 -2
  46. package/src/control/radio-group.ts +4 -2
  47. package/src/control/text-area.ts +394 -31
  48. package/src/control/text-input.ts +38 -1
  49. package/src/data/feed.ts +102 -3
  50. package/src/data/list.ts +125 -18
  51. package/src/data/markdown-view.ts +56 -0
  52. package/src/data/shared.ts +25 -3
  53. package/src/display/color-text.ts +386 -0
  54. package/src/display/index.ts +3 -0
  55. package/src/layout/divider.ts +15 -7
  56. package/src/layout/scroll-view.ts +12 -2
  57. package/src/navigation/menu.ts +89 -14
  58. package/src/overlay/command-palette.ts +125 -11
  59. package/src/shells/workbench-shell.ts +22 -3
@@ -0,0 +1,246 @@
1
+ import { attrsFromStyle, defineComponent, flattenStyleInput, graphemeWidth, graphemes, h, mergeStyles, mix, packColor, resolveColor, sanitize, stringWidth, styleFromProps, truncate, truncateSideOf, unpackColor, useMeasure, wrapModeOf, wrapText, } from '@textui/core';
2
+ /**
3
+ * Multiline text coloured cell by cell.
4
+ *
5
+ * A `text` takes one colour for the whole run, which is the right answer for
6
+ * nearly everything and no answer at all for the cases where the colour *is*
7
+ * the content - a banner, a ramp across a title, a palette walked down a
8
+ * block of ascii art. This is that case, and only that case: it is still just
9
+ * text, and the alternative to it is a `text` node.
10
+ *
11
+ * It paints on a `canvas`, and the price of the escape hatch is that inherited
12
+ * colour stops here. A cell the ink declines takes this component's own `fg`
13
+ * rather than the one a parent row would have handed a `text`, so a
14
+ * `ColorText` inside something that recolours its children when selected has
15
+ * to be told about it. Everything an ink does paint is unaffected.
16
+ *
17
+ * ```tsx
18
+ * <ColorText ink={{ gradient: ['cyan', 'magenta'] }}>{banner}</ColorText>
19
+ * <ColorText ink={['danger', 'warning', 'success']}>{lines}</ColorText>
20
+ * <ColorText ink={{ cycle: palette, every: [4, 3] }}>{title}</ColorText>
21
+ * <ColorText ink={(cell) => (cell.line === cell.col ? 'accent' : undefined)}>{grid}</ColorText>
22
+ * ```
23
+ *
24
+ * The colour is decoration and never the message: a 16-colour session flattens
25
+ * a three-stop ramp into a couple of bands, and a piped log loses all of it.
26
+ */
27
+ export const ColorText = defineComponent('ColorText', (props) => {
28
+ const { content, children, ink, ellipsis, alignBlock, ...rest } = props;
29
+ const source = sanitize(textOf(content, children));
30
+ const wrap = (props.wrap ?? 'none');
31
+ // The width layout settled on last frame, for the height this frame asks
32
+ // for. Painting uses the surface's own width instead - that one is current -
33
+ // so the lag here costs a line count and never a wrong line break.
34
+ const measured = useMeasure().width;
35
+ const natural = source.split('\n').reduce((w, line) => Math.max(w, stringWidth(line)), 0);
36
+ const height = linesOf(source, wrap, measured > 0 ? measured : natural).length;
37
+ // A block that does not wrap is as wide as its widest line and says so -
38
+ // that is what a banner is. One that wraps or truncates asks for nothing and
39
+ // takes what it is given, because a paragraph that reported the width of its
40
+ // unbroken text would push every sibling off the row to get it.
41
+ const width = wrap === 'none' ? natural : 0;
42
+ const draw = (surface, ctx) => {
43
+ const { width: area, height: rows } = surface.rect;
44
+ if (area <= 0 || rows <= 0 || source === '')
45
+ return;
46
+ // The component's own style, resolved for the state it is in. Not the
47
+ // inherited one - a canvas is not told what it was nested in.
48
+ const own = mergeStyles(styleFromProps(props), flattenStyleInput(props.style, stateOf(ctx)));
49
+ const rest = {
50
+ fg: resolveColor(own.fg, ctx.theme, 'default'),
51
+ attrs: attrsFromStyle(own),
52
+ link: typeof props.link === 'string' ? props.link : undefined,
53
+ };
54
+ const cut = props.truncate ?? truncateSideOf(wrap) ?? 'end';
55
+ const dots = ellipsis ?? ctx.theme.glyphs.ellipsis;
56
+ const lines = linesOf(source, wrap, area).map((line) => (stringWidth(line) > area && cut !== false ? truncate(line, area, dots, cut) : line));
57
+ // After truncating, not before: the width the block is placed by is the
58
+ // width it is going to occupy.
59
+ const blockWidth = lines.reduce((w, line) => Math.max(w, stringWidth(line)), 0);
60
+ const paint = painterOf(ink, ctx);
61
+ const align = own.textAlign ?? 'left';
62
+ const origin = alignBlock ? indentFor(align, area, blockWidth) : undefined;
63
+ let offset = 0;
64
+ for (let y = 0; y < lines.length && y < rows; y++) {
65
+ const line = lines[y];
66
+ const lineWidth = stringWidth(line);
67
+ const indent = origin ?? indentFor(align, area, lineWidth);
68
+ let col = 0;
69
+ let index = 0;
70
+ for (const char of graphemes(line)) {
71
+ const advance = graphemeWidth(char);
72
+ if (advance === 0)
73
+ continue;
74
+ const style = paint({
75
+ char, col, line: y, index, offset, width: lineWidth, height: lines.length, blockWidth,
76
+ });
77
+ // A space with nothing but a foreground has nothing to show, and
78
+ // painting it would wipe out whatever the box behind it drew there.
79
+ if (char !== ' ' || style?.bg !== undefined || rest.bg !== undefined) {
80
+ surface.put(indent + col, y, char, style ? { ...rest, ...style } : rest);
81
+ }
82
+ col += advance;
83
+ index++;
84
+ offset++;
85
+ }
86
+ }
87
+ };
88
+ return h('canvas', { draw, intrinsic: { width, height }, ...rest });
89
+ });
90
+ // ------------------------------------------------------------------- inks
91
+ /**
92
+ * A colour a fraction of the way along a set of stops.
93
+ *
94
+ * Exported because an ink written by hand wants it and the alternative is
95
+ * unpacking colours in application code. `t` outside 0..1 is clamped rather
96
+ * than extrapolated - a ramp has ends.
97
+ */
98
+ export function gradientAt(stops, t) {
99
+ if (stops.length === 0)
100
+ return 'default';
101
+ if (stops.length === 1)
102
+ return stops[0];
103
+ const at = Math.min(1, Math.max(0, t)) * (stops.length - 1);
104
+ const i = Math.min(stops.length - 2, Math.floor(at));
105
+ return blend(stops[i], stops[i + 1], at - i);
106
+ }
107
+ /** Two colours mixed. `t` of 0 is `a`, 1 is `b`. Tokens must be resolved first. */
108
+ export function blend(a, b, t) {
109
+ return unpackColor(mix(packColor(a), packColor(b), Math.min(1, Math.max(0, t))));
110
+ }
111
+ /**
112
+ * An ink in any of its spellings, as the one function paint uses.
113
+ *
114
+ * Exported so a component that paints its own cells - a chart, a viewer with a
115
+ * heat column - can take an `Ink` and mean the same thing by it.
116
+ */
117
+ export function painterOf(ink, ctx) {
118
+ if (ink === undefined)
119
+ return () => undefined;
120
+ if (typeof ink === 'function') {
121
+ return (cell) => styleOf(ink(cell, ctx), ctx);
122
+ }
123
+ if (Array.isArray(ink)) {
124
+ return painterOf({ cycle: ink, unit: 'line' }, ctx);
125
+ }
126
+ if ('gradient' in ink) {
127
+ const stops = ink.gradient.map((c) => resolveColor(c, ctx.theme, 'default'));
128
+ const axis = ink.axis ?? 'x';
129
+ const per = ink.per ?? 'block';
130
+ return (cell) => {
131
+ const span = per === 'line' ? cell.width : cell.blockWidth;
132
+ // A single column or a single line has no distance to ramp over, and
133
+ // dividing by zero would put every cell at the far end of the ramp.
134
+ const x = span > 1 ? cell.col / (span - 1) : 0;
135
+ const y = cell.height > 1 ? cell.line / (cell.height - 1) : 0;
136
+ const t = axis === 'x' ? x : axis === 'y' ? y : (x + y) / 2;
137
+ return { fg: gradientAt(stops, t) };
138
+ };
139
+ }
140
+ const colors = ink.cycle.map((c) => resolveColor(c, ctx.theme, 'default'));
141
+ if (colors.length === 0)
142
+ return () => undefined;
143
+ const unit = ink.unit ?? 'cell';
144
+ const runs = (Array.isArray(ink.every) ? ink.every : [ink.every ?? 1])
145
+ .map((n) => Math.max(1, Math.floor(n)));
146
+ const continuous = ink.continuous ?? false;
147
+ // `letter` and `word` are counted rather than derived, because both depend
148
+ // on what came before them on the line and neither can be recovered from a
149
+ // column number. Kept per line so the run restarts where the line does.
150
+ let letters = 0;
151
+ let words = 0;
152
+ let wasBlank = true;
153
+ let at = -1;
154
+ return (cell) => {
155
+ if (cell.line !== at) {
156
+ at = cell.line;
157
+ if (!continuous) {
158
+ letters = 0;
159
+ words = 0;
160
+ }
161
+ wasBlank = true;
162
+ }
163
+ const blank = cell.char.trim() === '';
164
+ if (!blank && wasBlank)
165
+ words++;
166
+ if (!blank)
167
+ letters++;
168
+ wasBlank = blank;
169
+ const n = unit === 'cell' ? (continuous ? cell.offset : cell.col)
170
+ : unit === 'grapheme' ? (continuous ? cell.offset : cell.index)
171
+ : unit === 'letter' ? Math.max(0, letters - 1)
172
+ : unit === 'word' ? Math.max(0, words - 1)
173
+ : cell.line;
174
+ return { fg: colors[runIndex(n, runs) % colors.length] };
175
+ };
176
+ }
177
+ /** Which run of a repeating pattern a count falls in. `[4, 3]` over 8 gives 2. */
178
+ function runIndex(n, runs) {
179
+ const total = runs.reduce((a, b) => a + b, 0);
180
+ const cycles = Math.floor(n / total);
181
+ let rest = n % total;
182
+ let i = 0;
183
+ while (rest >= runs[i]) {
184
+ rest -= runs[i];
185
+ i++;
186
+ }
187
+ return cycles * runs.length + i;
188
+ }
189
+ /** A colour, a style, or nothing - as a style, with its tokens resolved. */
190
+ function styleOf(value, ctx) {
191
+ if (value === undefined)
192
+ return undefined;
193
+ if (typeof value === 'string')
194
+ return { fg: resolveColor(value, ctx.theme, 'default') };
195
+ if ('rgb' in value || 'palette' in value)
196
+ return { fg: value };
197
+ const style = value;
198
+ return {
199
+ ...style,
200
+ fg: style.fg === undefined ? undefined : resolveColor(style.fg, ctx.theme, 'default'),
201
+ bg: style.bg === undefined ? undefined : resolveColor(style.bg, ctx.theme, 'default'),
202
+ };
203
+ }
204
+ // ------------------------------------------------------------------ text
205
+ function textOf(content, children) {
206
+ if (typeof content === 'string')
207
+ return content;
208
+ if (typeof content === 'number')
209
+ return String(content);
210
+ if (typeof children === 'string')
211
+ return children;
212
+ if (typeof children === 'number')
213
+ return String(children);
214
+ if (Array.isArray(children)) {
215
+ return children.filter((c) => typeof c === 'string' || typeof c === 'number').join('');
216
+ }
217
+ return '';
218
+ }
219
+ /** Where a line of `width` starts, in a box of `area`. */
220
+ function indentFor(align, area, width) {
221
+ if (align === 'center')
222
+ return Math.max(0, Math.floor((area - width) / 2));
223
+ if (align === 'right')
224
+ return Math.max(0, area - width);
225
+ return 0;
226
+ }
227
+ /** The same line-breaking `text` does, so the two agree about where a line ends. */
228
+ function linesOf(text, wrap, width) {
229
+ if (text === '')
230
+ return [];
231
+ // The truncating modes are one line by definition; a newline inside one has
232
+ // nowhere to go, so it becomes a space rather than silently taking the rest
233
+ // of the text with it.
234
+ if (truncateSideOf(wrap) !== undefined)
235
+ return [text.replace(/\n/g, ' ')];
236
+ const mode = wrapModeOf(wrap);
237
+ if (mode === 'none' || width <= 0)
238
+ return text.split('\n');
239
+ return wrapText(text, width, mode);
240
+ }
241
+ function stateOf(ctx) {
242
+ return {
243
+ focused: ctx.focused, hovered: ctx.hovered, active: ctx.active,
244
+ selected: ctx.selected, disabled: ctx.disabled,
245
+ };
246
+ }
@@ -10,6 +10,7 @@ import type { ComponentDefinition } from '@textui/core';
10
10
  export * from './alert.js';
11
11
  export * from './badge.js';
12
12
  export * from './card.js';
13
+ export * from './color-text.js';
13
14
  export * from './empty-state.js';
14
15
  export * from './error-state.js';
15
16
  export * from './heading.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/display/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAgBxD;;;;;;;GAOG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAE9B,eAAO,MAAM,kBAAkB,EAAE,mBAAmB,EAenD,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/display/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAiBxD;;;;;;;GAOG;AACH,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAE9B,eAAO,MAAM,kBAAkB,EAAE,mBAAmB,EAgBnD,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import { Alert } from './alert.js';
2
2
  import { Badge } from './badge.js';
3
3
  import { Card } from './card.js';
4
+ import { ColorText } from './color-text.js';
4
5
  import { EmptyState } from './empty-state.js';
5
6
  import { ErrorState } from './error-state.js';
6
7
  import { Heading } from './heading.js';
@@ -23,6 +24,7 @@ import { Timeline } from './timeline.js';
23
24
  export * from './alert.js';
24
25
  export * from './badge.js';
25
26
  export * from './card.js';
27
+ export * from './color-text.js';
26
28
  export * from './empty-state.js';
27
29
  export * from './error-state.js';
28
30
  export * from './heading.js';
@@ -44,6 +46,7 @@ export const DISPLAY_COMPONENTS = [
44
46
  { component: 'KeyValue', category: 'data', renderer: { kind: 'function', render: KeyValue }, description: 'Aligned label/value pairs.' },
45
47
  { component: 'Progress', category: 'feedback', renderer: { kind: 'function', render: Progress }, role: 'progressbar', description: 'Determinate or indeterminate bar, sub-cell resolution.' },
46
48
  { component: 'Spinner', category: 'feedback', renderer: { kind: 'function', render: Spinner }, role: 'status', description: 'Animated activity indicator.' },
49
+ { 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.' },
47
50
  { 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.' },
48
51
  { component: 'Skeleton', category: 'feedback', renderer: { kind: 'function', render: Skeleton }, description: 'Loading placeholder.' },
49
52
  { component: 'EmptyState', category: 'feedback', renderer: { kind: 'function', render: EmptyState }, description: 'Nothing here, and what to do about it.' },
@@ -1,10 +1,15 @@
1
- import type { BoxProps } from '@textui/core';
1
+ import type { BoxProps, DividerStyle } from '@textui/core';
2
2
  export interface DividerProps extends Omit<BoxProps, 'direction'> {
3
3
  /** A divider runs across the flow, so it names its own axis. */
4
4
  direction?: 'horizontal' | 'vertical';
5
5
  /** Text set into the rule. */
6
6
  label?: string;
7
7
  labelAlign?: 'left' | 'center' | 'right';
8
+ /**
9
+ * The rule style. The theme's own is the default, so a borderless theme
10
+ * still gets the line it asked for.
11
+ */
12
+ rule?: DividerStyle;
8
13
  char?: string;
9
14
  }
10
15
  export declare const Divider: (props: DividerProps) => import("@textui/core").RenderOutput;
@@ -1 +1 @@
1
- {"version":3,"file":"divider.d.ts","sourceRoot":"","sources":["../../src/layout/divider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAG7C,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/D,gEAAgE;IAChE,SAAS,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACtC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,OAAO,8DAkBlB,CAAC"}
1
+ {"version":3,"file":"divider.d.ts","sourceRoot":"","sources":["../../src/layout/divider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG3D,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/D,gEAAgE;IAChE,SAAS,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACtC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;IACzC;;;OAGG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,OAAO,8DAqBlB,CAAC"}
@@ -1,13 +1,13 @@
1
1
  import { defineComponent, h, useTheme } from '@textui/core';
2
2
  export const Divider = defineComponent('Divider', (props) => {
3
3
  const theme = useTheme();
4
- const { direction = 'horizontal', label, labelAlign = 'left', char, ...rest } = props;
5
- const chars = theme.borderChars();
4
+ const { direction = 'horizontal', label, labelAlign = 'left', char, rule: ruleStyle, fg = 'divider', ...rest } = props;
5
+ const rule = theme.dividerChars(ruleStyle);
6
6
  if (direction === 'vertical') {
7
- return h('box', { role: 'separator', width: 1, fill: char ?? chars.left, fg: 'border', ...rest });
7
+ return h('box', { role: 'separator', width: 1, fill: char ?? rule.vertical, fg, ...rest });
8
8
  }
9
9
  if (!label) {
10
- return h('box', { role: 'separator', height: 1, fill: char ?? chars.top, fg: 'border', ...rest });
10
+ return h('box', { role: 'separator', height: 1, fill: char ?? rule.horizontal, fg, ...rest });
11
11
  }
12
- return h('box', { direction: 'row', gap: 1, height: 1, ...rest }, labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null, h('text', { content: label, fg: 'muted' }), labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null);
12
+ return h('box', { direction: 'row', gap: 1, height: 1, ...rest }, labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null, h('text', { content: label, fg: 'muted' }), labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null);
13
13
  });
@@ -15,6 +15,16 @@ export interface ScrollViewProps extends BoxProps {
15
15
  */
16
16
  focusable?: boolean;
17
17
  autoFocus?: boolean;
18
+ /**
19
+ * A stable focus id, so a command - or the screen that owns this - can put
20
+ * the reader here by name.
21
+ *
22
+ * Without one the id comes from the instance, which nothing outside the
23
+ * render can know: "scroll the preview" has nothing to name and the key that
24
+ * would do it cannot be written. Every other focusable control takes one;
25
+ * this was the exception, and there was no reason for it.
26
+ */
27
+ focusId?: string;
18
28
  }
19
29
  /**
20
30
  * A scrolling viewport.
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-view.d.ts","sourceRoot":"","sources":["../../src/layout/scroll-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAa7C,MAAM,WAAW,eAAgB,SAAQ,QAAQ;IAC/C,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,iEA4ErB,CAAC"}
1
+ {"version":3,"file":"scroll-view.d.ts","sourceRoot":"","sources":["../../src/layout/scroll-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAa7C,MAAM,WAAW,eAAgB,SAAQ,QAAQ;IAC/C,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,iEA4ErB,CAAC"}
@@ -8,8 +8,8 @@ import { ScrollThumb } from '../data/scroll-thumb.js';
8
8
  * rounds.
9
9
  */
10
10
  export const ScrollView = defineComponent('ScrollView', (props) => {
11
- const { offset, onScroll, scrollbar = true, focusable = true, autoFocus, id, children, ...rest } = props;
12
- const focus = useFocus({ disabled: !focusable, autoFocus });
11
+ const { offset, onScroll, scrollbar = true, focusable = true, autoFocus, focusId, id, children, ...rest } = props;
12
+ const focus = useFocus({ ...(focusId ? { id: focusId } : {}), disabled: !focusable, autoFocus });
13
13
  const [internal, setInternal] = useState(0);
14
14
  const top = offset ?? internal;
15
15
  // How far down this can go before the last line leaves the bottom. Written
@@ -9,6 +9,18 @@ export interface MenuItem {
9
9
  disabled?: boolean;
10
10
  tone?: SemanticVariant;
11
11
  separatorBefore?: boolean;
12
+ /**
13
+ * A heading on the line above this row, naming the group it starts.
14
+ *
15
+ * The name of a group belongs to the group, so it is said once at the top
16
+ * of it rather than repeated on every row - a column reading "Screens,
17
+ * Screens, Screens" spends the width that the rows themselves need, and
18
+ * still does not say where one group ends.
19
+ *
20
+ * It takes the line a `separatorBefore` would have used rather than adding
21
+ * one, so a grouped menu is the same height either way.
22
+ */
23
+ sectionBefore?: string;
12
24
  /**
13
25
  * A switch, and whether it is on. Absent means the row is not a switch, so
14
26
  * a menu of ordinary commands keeps its left edge rather than indenting
@@ -24,6 +36,18 @@ export interface MenuProps extends BoxProps {
24
36
  visibleRows?: number;
25
37
  activeId?: string;
26
38
  autoFocus?: boolean;
39
+ /**
40
+ * Where a row's description goes.
41
+ *
42
+ * `inline` right-aligns it on the row, sharing the width with the label -
43
+ * which is the right shape for a word or two of state. `below` gives it a
44
+ * line of its own under the label, indented to it, which is the only shape
45
+ * that fits a sentence: inline, a list of modes whose whole difference is
46
+ * the sentence under each shows the same truncated half of every one.
47
+ *
48
+ * `below` makes every row two lines, so `visibleRows` buys half as much.
49
+ */
50
+ descriptions?: 'inline' | 'below';
27
51
  /**
28
52
  * Take focus and handle keys. Off when something else drives the selection -
29
53
  * a command palette, where typing belongs to the search field and the list
@@ -1 +1 @@
1
- {"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../src/navigation/menu.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAK9D,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,QAAQ,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,QAAQ,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5C,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,eAAO,MAAM,IAAI,2DA8Ff,CAAC"}
1
+ {"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../src/navigation/menu.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAK9D,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,QAAQ,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,QAAQ,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC5C,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;OAUG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,eAAO,MAAM,IAAI,2DAkIf,CAAC"}
@@ -1,9 +1,9 @@
1
- import { defineComponent, h, useFocus, useInput, useState, useTheme } from '@textui/core';
1
+ import { defineComponent, h, stringWidth, useFocus, useInput, useState, useTheme } from '@textui/core';
2
2
  import { Marquee } from '../display/index.js';
3
3
  import { TONE } from '../tone.js';
4
4
  export const Menu = defineComponent('Menu', (props) => {
5
5
  const theme = useTheme();
6
- const { items, onSelect, visibleRows, activeId, autoFocus, interactive = true, ...rest } = props;
6
+ const { items, onSelect, visibleRows, activeId, autoFocus, interactive = true, descriptions = 'inline', ...rest } = props;
7
7
  const focus = useFocus({ autoFocus, disabled: !interactive });
8
8
  const selectable = items.filter((i) => !i.disabled);
9
9
  const [internalHighlight, setHighlight] = useState(Math.max(0, items.findIndex((i) => i.id === activeId)));
@@ -43,18 +43,8 @@ export const Menu = defineComponent('Menu', (props) => {
43
43
  const switches = items.some((item) => item.checked !== undefined);
44
44
  return h('box', { id: focus.id, role: 'menu', direction: 'column', ...rest }, ...window.flatMap((item, i) => {
45
45
  const active = start + i === highlight;
46
- const row = h('box', {
47
- key: item.id,
48
- role: 'menuitem',
49
- label: item.label,
50
- selected: active,
51
- direction: 'row',
52
- gap: 1,
53
- bg: active ? 'selected' : undefined,
54
- fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined,
55
- onClick: () => { if (!item.disabled)
56
- onSelect?.(item.id, item); },
57
- }, h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 }), switches
46
+ const below = descriptions === 'below' && item.description !== undefined;
47
+ const head = h('box', { direction: 'row', gap: 1 }, h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 }), switches
58
48
  ? h('text', { content: item.checked === true ? theme.glyphs.check : ' ', shrink: 0 })
59
49
  : null,
60
50
  // The mark never gives up room. It is the shortest thing on the row
@@ -67,7 +57,7 @@ export const Menu = defineComponent('Menu', (props) => {
67
57
  // The description yields first, and by a lot. It is the elaboration;
68
58
  // the label is the thing being chosen, and a row reading "Accept ed…"
69
59
  // beside a full sentence has given up the wrong half.
70
- item.description
60
+ item.description && !below
71
61
  ? h(Marquee, {
72
62
  content: item.description,
73
63
  active,
@@ -79,6 +69,43 @@ export const Menu = defineComponent('Menu', (props) => {
79
69
  // which is what the command palette knows about an argument whose
80
70
  // choices it has not resolved yet.
81
71
  item.children ? h('text', { content: theme.glyphs.chevronRight }) : null);
72
+ const row = h('box', {
73
+ key: item.id,
74
+ role: 'menuitem',
75
+ label: item.label,
76
+ selected: active,
77
+ direction: 'column',
78
+ // One background over both lines: a highlight that stopped after the
79
+ // label would split the row it is highlighting in two.
80
+ bg: active ? 'selected' : undefined,
81
+ fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined,
82
+ onClick: () => { if (!item.disabled)
83
+ onSelect?.(item.id, item); },
84
+ }, head,
85
+ // Under the label rather than under the cursor: the sentence is about
86
+ // the thing being chosen, so it starts where that thing starts.
87
+ below
88
+ ? h('box', { direction: 'row' }, h('text', { content: ' '.repeat(leading(item, switches)), shrink: 0 }),
89
+ // A `Marquee`, like the inline one: a sentence too long for the
90
+ // panel is still readable on the row the cursor is on, by sliding
91
+ // it. Truncated and still is right for the rows being scanned past
92
+ // and useless for the one that has been stopped on.
93
+ h(Marquee, {
94
+ content: item.description,
95
+ active,
96
+ fg: active ? 'inverted' : 'muted',
97
+ flex: 1,
98
+ }))
99
+ : null);
100
+ if (item.sectionBefore) {
101
+ return [
102
+ h('box', { key: `${item.id}-sec`, role: 'heading', direction: 'row', gap: 1 },
103
+ // The same leading columns the rows have, so a heading sits over
104
+ // the labels it names rather than over the cursor's gutter.
105
+ h('text', { content: ' ', shrink: 0 }), switches ? h('text', { content: ' ', shrink: 0 }) : null, h('text', { content: item.sectionBefore, bold: true, fg: 'muted', truncate: 'end' })),
106
+ row,
107
+ ];
108
+ }
82
109
  return item.separatorBefore
83
110
  ? [h('box', { key: `${item.id}-sep`, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }), row]
84
111
  : [row];
@@ -86,3 +113,19 @@ export const Menu = defineComponent('Menu', (props) => {
86
113
  ? h('text', { content: ' no matches', fg: 'subtle' })
87
114
  : null);
88
115
  });
116
+ /**
117
+ * The columns before a row's label, so a second line can start under it.
118
+ *
119
+ * The cursor's column and the switch's are the same on every row - that is
120
+ * what keeps a menu's left edge straight - but the icon is per-row and may be
121
+ * two cells wide, so it has to be measured rather than assumed.
122
+ */
123
+ function leading(item, switches) {
124
+ // The marker, plus the gap after it.
125
+ let width = 2;
126
+ if (switches)
127
+ width += 2;
128
+ if (item.icon)
129
+ width += stringWidth(item.icon) + 1;
130
+ return width;
131
+ }
@@ -14,10 +14,41 @@ export interface CommandPaletteProps extends BoxProps {
14
14
  onClose?(): void;
15
15
  /** Off makes this a picker: it reports the choice and runs nothing. */
16
16
  execute?: boolean;
17
- /** Group the list by `category`, with a rule between groups. */
17
+ /**
18
+ * Group the list by `category`, with the category named above each group.
19
+ *
20
+ * Only while nothing is typed. A query sorts by relevance, which interleaves
21
+ * the categories - and a heading over one row is not a group.
22
+ */
18
23
  grouped?: boolean;
19
24
  visibleRows?: number;
25
+ /**
26
+ * A fixed width, in cells.
27
+ *
28
+ * Left off, the panel is as wide as its widest row and no wider than
29
+ * `maxWidth` - which is what a list of five short answers wants, and what a
30
+ * list of five sentences needs. A number here is a number: the panel is that
31
+ * wide whether the rows fill it or overflow it.
32
+ */
20
33
  width?: number;
34
+ /**
35
+ * The widest the panel may grow when `width` is left off. 60 by default.
36
+ *
37
+ * There is always a limit: a description is prose, and prose has no width it
38
+ * stops at. Past this the rows truncate, and the row under the cursor slides
39
+ * what it truncated.
40
+ */
41
+ maxWidth?: number;
42
+ /**
43
+ * Where a row's description goes. `inline` right-aligns it beside the label;
44
+ * `below` gives it a line of its own.
45
+ *
46
+ * `below` for a question whose answers differ by a sentence rather than by a
47
+ * word - four approval modes named in two words each are told apart by the
48
+ * line under them, and inline that line is the half that gets truncated.
49
+ * Every row costs two lines, so `visibleRows` buys half as many.
50
+ */
51
+ descriptions?: 'inline' | 'below';
21
52
  /**
22
53
  * Open already drilled into this command's choices.
23
54
  *
@@ -1 +1 @@
1
- {"version":3,"file":"command-palette.d.ts","sourceRoot":"","sources":["../../src/overlay/command-palette.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAa,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAa,MAAM,cAAc,CAAC;AAgB/F,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,GAAG,CAAC,MAAM,iBAAiB,EAAE,CAAC,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,qEA0VzB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AAqBD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}
1
+ {"version":3,"file":"command-palette.d.ts","sourceRoot":"","sources":["../../src/overlay/command-palette.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAa,OAAO,EAAE,QAAQ,EAAE,iBAAiB,EAAa,MAAM,cAAc,CAAC;AAiB/F,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,iBAAiB,EAAE,GAAG,CAAC,MAAM,iBAAiB,EAAE,CAAC,CAAC;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,KAAK,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;IAClC;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,qEAsZzB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AAqBD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}