@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.
- package/README.md +17 -15
- package/dist/control/checkbox.d.ts +2 -0
- package/dist/control/checkbox.d.ts.map +1 -1
- package/dist/control/checkbox.js +2 -2
- package/dist/control/radio-group.d.ts +2 -0
- package/dist/control/radio-group.d.ts.map +1 -1
- package/dist/control/radio-group.js +2 -2
- package/dist/control/text-area.d.ts +15 -2
- package/dist/control/text-area.d.ts.map +1 -1
- package/dist/control/text-area.js +402 -22
- package/dist/control/text-input.d.ts.map +1 -1
- package/dist/control/text-input.js +40 -0
- package/dist/data/feed.d.ts +14 -0
- package/dist/data/feed.d.ts.map +1 -1
- package/dist/data/feed.js +28 -2
- package/dist/data/list.d.ts +60 -7
- package/dist/data/list.d.ts.map +1 -1
- package/dist/data/list.js +66 -10
- package/dist/data/markdown-view.d.ts.map +1 -1
- package/dist/data/markdown-view.js +49 -0
- package/dist/display/color-text.d.ts +158 -0
- package/dist/display/color-text.d.ts.map +1 -0
- package/dist/display/color-text.js +246 -0
- package/dist/display/index.d.ts +1 -0
- package/dist/display/index.d.ts.map +1 -1
- package/dist/display/index.js +3 -0
- package/dist/layout/divider.d.ts +6 -1
- package/dist/layout/divider.d.ts.map +1 -1
- package/dist/layout/divider.js +5 -5
- package/dist/layout/scroll-view.d.ts +10 -0
- package/dist/layout/scroll-view.d.ts.map +1 -1
- package/dist/layout/scroll-view.js +2 -2
- package/dist/navigation/menu.d.ts +24 -0
- package/dist/navigation/menu.d.ts.map +1 -1
- package/dist/navigation/menu.js +58 -15
- package/dist/overlay/command-palette.d.ts +32 -1
- package/dist/overlay/command-palette.d.ts.map +1 -1
- package/dist/overlay/command-palette.js +84 -10
- package/dist/shells/workbench-shell.d.ts.map +1 -1
- package/dist/shells/workbench-shell.js +16 -2
- package/package.json +8 -8
- package/src/control/checkbox.ts +4 -2
- package/src/control/radio-group.ts +4 -2
- package/src/control/text-area.ts +394 -31
- package/src/control/text-input.ts +38 -1
- package/src/data/feed.ts +44 -1
- package/src/data/list.ts +125 -18
- package/src/data/markdown-view.ts +56 -0
- package/src/display/color-text.ts +386 -0
- package/src/display/index.ts +3 -0
- package/src/layout/divider.ts +15 -7
- package/src/layout/scroll-view.ts +12 -2
- package/src/navigation/menu.ts +89 -14
- package/src/overlay/command-palette.ts +125 -11
- 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
|
+
}
|
package/src/display/index.ts
CHANGED
|
@@ -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.' },
|
package/src/layout/divider.ts
CHANGED
|
@@ -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 {
|
|
16
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
package/src/navigation/menu.ts
CHANGED
|
@@ -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,
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|