@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
package/src/data/feed.ts
CHANGED
|
@@ -33,6 +33,20 @@ export interface FeedProps extends BoxProps {
|
|
|
33
33
|
onSelect?(index: number): void;
|
|
34
34
|
onActivate?(index: number): void;
|
|
35
35
|
scrollbar?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Who `pageup` and `pagedown` belong to.
|
|
38
|
+
*
|
|
39
|
+
* `focused` is the ordinary answer: the keys go to whatever has the
|
|
40
|
+
* keyboard. `always` claims them even while something else does - for the
|
|
41
|
+
* feed that *is* the screen, with a text field under it. Somebody typing a
|
|
42
|
+
* message who presses page up means the conversation above them; there is
|
|
43
|
+
* nothing else on that screen those keys could be for, and taking the
|
|
44
|
+
* keyboard away from the field to use them is the thing they are avoiding.
|
|
45
|
+
*
|
|
46
|
+
* Only those two keys, and only when the focused node has declined them
|
|
47
|
+
* first - so a field that pages its own content keeps them.
|
|
48
|
+
*/
|
|
49
|
+
pageKeys?: 'focused' | 'always';
|
|
36
50
|
focusable?: boolean;
|
|
37
51
|
autoFocus?: boolean;
|
|
38
52
|
/** So a command can send the reader here by name. */
|
|
@@ -59,6 +73,7 @@ export interface FeedProps extends BoxProps {
|
|
|
59
73
|
export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
60
74
|
const {
|
|
61
75
|
children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate,
|
|
76
|
+
pageKeys = 'focused',
|
|
62
77
|
scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest
|
|
63
78
|
} = props;
|
|
64
79
|
|
|
@@ -86,7 +101,17 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
86
101
|
const [internalTop, setInternalTop] = useState<number | null>(null);
|
|
87
102
|
const [internalIndex, setInternalIndex] = useState(0);
|
|
88
103
|
|
|
89
|
-
|
|
104
|
+
/*
|
|
105
|
+
* Flattened, because a feed counts entries and JSX groups them.
|
|
106
|
+
*
|
|
107
|
+
* `{caption}{items.map(...)}` arrives as `[caption, [a, b, c]]` - two
|
|
108
|
+
* children, one of which is an array - so a feed of twenty entries with
|
|
109
|
+
* anything written beside the map counted two. The cursor then clamped to
|
|
110
|
+
* the second, every key after the first did nothing, and the scroll never
|
|
111
|
+
* moved. It reads as a dead list and is an arity bug.
|
|
112
|
+
*/
|
|
113
|
+
const entries = (Array.isArray(children) ? children.flat(Infinity) : [children])
|
|
114
|
+
.filter((c) => c != null);
|
|
90
115
|
const count = entries.length;
|
|
91
116
|
const selects = onSelect !== undefined || selectedIndex !== undefined;
|
|
92
117
|
const index = Math.max(0, Math.min(count - 1, selectedIndex ?? internalIndex));
|
|
@@ -166,6 +191,24 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
|
|
|
166
191
|
{ focusId: focus.id, enabled: focusable },
|
|
167
192
|
);
|
|
168
193
|
|
|
194
|
+
/*
|
|
195
|
+
* The page keys, from wherever the keyboard happens to be.
|
|
196
|
+
*
|
|
197
|
+
* `global` handlers run only after the focused node has declined the key,
|
|
198
|
+
* so a field that pages its own content still keeps them - this is the one
|
|
199
|
+
* that catches what nothing else wanted.
|
|
200
|
+
*/
|
|
201
|
+
useInput(
|
|
202
|
+
(event) => {
|
|
203
|
+
if (event.name !== 'pageup' && event.name !== 'pagedown') return false;
|
|
204
|
+
if (chorded(event)) return false;
|
|
205
|
+
const page = Math.max(1, measured.height - 2);
|
|
206
|
+
scrollTo(event.name === 'pageup' ? top - page : top + page);
|
|
207
|
+
return true;
|
|
208
|
+
},
|
|
209
|
+
{ global: true, enabled: pageKeys === 'always' },
|
|
210
|
+
);
|
|
211
|
+
|
|
169
212
|
const drawn = entries.map((entry, i) => h(FeedEntry, {
|
|
170
213
|
key: i,
|
|
171
214
|
onHeight: (height: number) => { heights.current[i] = height; },
|
package/src/data/list.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BoxProps, SemanticVariant } from '@textui/core';
|
|
1
|
+
import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
|
|
2
2
|
import {
|
|
3
3
|
chorded,
|
|
4
4
|
defineComponent,
|
|
@@ -24,15 +24,39 @@ export interface ListItem {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/** How a row stands at the moment it is asked to draw itself. */
|
|
28
|
+
export interface ListItemState {
|
|
29
|
+
/** The row the selection is on. */
|
|
30
|
+
selected: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* ...and the list itself has the keyboard.
|
|
33
|
+
*
|
|
34
|
+
* Both, and not one: a selected row in a list that has lost focus is a
|
|
35
|
+
* *remembered* choice, and drawing it as loudly as a live one makes two
|
|
36
|
+
* panes look like they both have the cursor.
|
|
37
|
+
*/
|
|
38
|
+
focused: boolean;
|
|
39
|
+
disabled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ListProps<T extends ListItem = ListItem> extends BoxProps {
|
|
43
|
+
/**
|
|
44
|
+
* The rows.
|
|
45
|
+
*
|
|
46
|
+
* `T` is whatever the caller's own row type is, so long as it is a
|
|
47
|
+
* `ListItem` - which is what the built-in row needs and what `id` being the
|
|
48
|
+
* selection's name needs. Passing plain `ListItem`s is the ordinary case and
|
|
49
|
+
* `T` costs nothing there; a caller with a `renderItem` gets its own fields
|
|
50
|
+
* back on the way in rather than a lookup by id.
|
|
51
|
+
*/
|
|
52
|
+
items: T[];
|
|
29
53
|
selectedId?: string;
|
|
30
|
-
onSelect?(id: string, item:
|
|
31
|
-
onActivate?(id: string, item:
|
|
54
|
+
onSelect?(id: string, item: T): void;
|
|
55
|
+
onActivate?(id: string, item: T): void;
|
|
32
56
|
/** Rows visible at once. Scrolls when there are more. */
|
|
33
57
|
visibleRows?: number;
|
|
34
58
|
emptyMessage?: string;
|
|
35
|
-
/** Draw a marker column for the selected row. */
|
|
59
|
+
/** Draw a marker column for the selected row. Drawn either way. */
|
|
36
60
|
marker?: boolean;
|
|
37
61
|
focusable?: boolean;
|
|
38
62
|
autoFocus?: boolean;
|
|
@@ -42,14 +66,42 @@ export interface ListProps extends BoxProps {
|
|
|
42
66
|
* nothing outside the render can know.
|
|
43
67
|
*/
|
|
44
68
|
focusId?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Draw one row's contents.
|
|
71
|
+
*
|
|
72
|
+
* The built-in row - icon, title, description, meta, on one line - is the
|
|
73
|
+
* shape most catalogues are, and it is what you get by leaving this alone.
|
|
74
|
+
* The moment a caller wants a different one, the repair is *not* another
|
|
75
|
+
* field on `ListItem` and another flag saying where to put it: that road
|
|
76
|
+
* ends with a component whose props are a small layout language, and it
|
|
77
|
+
* still cannot draw the row after next.
|
|
78
|
+
*
|
|
79
|
+
* So the row is the caller's, and everything a row cannot do for itself
|
|
80
|
+
* stays here: the selection, the keys that move it, the window that scrolls,
|
|
81
|
+
* the highlight, the marker column and the click. `state` is what the row
|
|
82
|
+
* cannot know - whether it is the selected one, and whether that selection
|
|
83
|
+
* is live.
|
|
84
|
+
*
|
|
85
|
+
* A row taller than one line has to say so with `itemHeight`.
|
|
86
|
+
*/
|
|
87
|
+
renderItem?(item: T, state: ListItemState): RenderOutput;
|
|
88
|
+
/**
|
|
89
|
+
* Lines one row occupies, when `renderItem` draws more than one.
|
|
90
|
+
*
|
|
91
|
+
* The list scrolls by arithmetic rather than by measurement - it decides how
|
|
92
|
+
* many rows fit *before* anything is drawn, which is the only way a thousand
|
|
93
|
+
* rows cost the same as ten. That arithmetic is in lines, so a row that is
|
|
94
|
+
* two of them has to be declared, not discovered.
|
|
95
|
+
*/
|
|
96
|
+
itemHeight?: number;
|
|
45
97
|
}
|
|
46
98
|
|
|
47
|
-
|
|
99
|
+
function ListView<T extends ListItem>(props: ListProps<T>): RenderOutput {
|
|
48
100
|
const theme = useTheme();
|
|
49
101
|
const {
|
|
50
102
|
items, selectedId, onSelect, onActivate, visibleRows,
|
|
51
103
|
emptyMessage = 'Nothing here', marker = true, focusable = true,
|
|
52
|
-
autoFocus, focusId, ...rest
|
|
104
|
+
autoFocus, focusId, renderItem, itemHeight = 1, ...rest
|
|
53
105
|
} = props;
|
|
54
106
|
|
|
55
107
|
const focus = useFocus({
|
|
@@ -62,7 +114,20 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
62
114
|
items.find((i) => !i.disabled)?.id ?? null,
|
|
63
115
|
);
|
|
64
116
|
const currentId = selectedId ?? internalId;
|
|
65
|
-
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How many *rows* fit, when what was measured is lines.
|
|
120
|
+
*
|
|
121
|
+
* `visibleRows` is already a count of rows, so it stands as given. Anything
|
|
122
|
+
* else comes from the space the list was handed, and a two-line row buys
|
|
123
|
+
* half as many of them.
|
|
124
|
+
*/
|
|
125
|
+
const lines = Math.max(1, itemHeight);
|
|
126
|
+
const fit = (content: number): number => {
|
|
127
|
+
if (visibleRows !== undefined) return Math.max(1, visibleRows);
|
|
128
|
+
return Math.max(1, Math.floor(viewportRows(props, measured, content * lines) / lines));
|
|
129
|
+
};
|
|
130
|
+
const pageSize = fit(10);
|
|
66
131
|
const index = Math.max(0, items.findIndex((i) => i.id === currentId));
|
|
67
132
|
|
|
68
133
|
/**
|
|
@@ -112,13 +177,22 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
112
177
|
// Unstated, the row count comes from the space the list was given rather
|
|
113
178
|
// than from how many items it holds - a thousand-row list must not decide
|
|
114
179
|
// how tall its own pane is.
|
|
115
|
-
const rows =
|
|
180
|
+
const rows = fit(items.length);
|
|
116
181
|
const start = Math.max(0, Math.min(index - Math.floor(rows / 2), items.length - rows));
|
|
117
182
|
const window = items.slice(start, start + rows);
|
|
118
183
|
|
|
119
184
|
return h('box', { id: focus.id, role: 'list', direction: 'column', ...rest },
|
|
120
185
|
...window.map((item) => {
|
|
121
186
|
const active = item.id === currentId;
|
|
187
|
+
const state: ListItemState = {
|
|
188
|
+
selected: active,
|
|
189
|
+
focused: focus.focused,
|
|
190
|
+
disabled: item.disabled === true,
|
|
191
|
+
};
|
|
192
|
+
// `item.id` is the key as well as the selection's name, and deliberately
|
|
193
|
+
// the same string: a row that reconciled under one identity while the
|
|
194
|
+
// selection pointed at another would be a highlight on the wrong row,
|
|
195
|
+
// and no amount of looking at either one would show why.
|
|
122
196
|
return h('box', {
|
|
123
197
|
key: item.id,
|
|
124
198
|
role: 'listitem',
|
|
@@ -126,6 +200,9 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
126
200
|
selected: active,
|
|
127
201
|
direction: 'row',
|
|
128
202
|
gap: 1,
|
|
203
|
+
// One background over the whole row, however many lines it draws: a
|
|
204
|
+
// highlight that stopped after the first would split the row it is
|
|
205
|
+
// highlighting in two.
|
|
129
206
|
bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
|
|
130
207
|
fg: item.disabled ? 'disabled' : active && focus.focused ? 'inverted' : undefined,
|
|
131
208
|
onClick: () => {
|
|
@@ -134,16 +211,46 @@ export const List = defineComponent<ListProps>('List', (props) => {
|
|
|
134
211
|
onSelect?.(item.id, item);
|
|
135
212
|
},
|
|
136
213
|
},
|
|
214
|
+
// The marker stays here even when the contents are the caller's. It
|
|
215
|
+
// belongs to the selection rather than to the row, and a column that
|
|
216
|
+
// every row has to remember to draw is a column that goes crooked.
|
|
137
217
|
marker
|
|
138
|
-
? h('text', { content: active ? theme.glyphs.chevronRight : ' ' })
|
|
218
|
+
? h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 })
|
|
139
219
|
: null,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// `muted` on a selected background is unreadable.
|
|
144
|
-
item.description ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end' }) : null,
|
|
145
|
-
item.meta ? h('text', { content: item.meta, fg: active ? undefined : 'muted' }) : null,
|
|
220
|
+
...(renderItem
|
|
221
|
+
? [h('box', { direction: 'column', flex: 1 }, renderItem(item, state))]
|
|
222
|
+
: defaultRow(item, active)),
|
|
146
223
|
);
|
|
147
224
|
}),
|
|
148
225
|
);
|
|
149
|
-
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const List = defineComponent('List', ListView as (props: ListProps) => RenderOutput) as typeof ListView;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The row you get for free: an icon, a title, an elaboration, a state.
|
|
232
|
+
*
|
|
233
|
+
* One line, because a list of a thousand of them has to cost what a list of
|
|
234
|
+
* ten does, and because most catalogues are exactly this. Anything else is
|
|
235
|
+
* `renderItem`.
|
|
236
|
+
*/
|
|
237
|
+
function defaultRow(item: ListItem, active: boolean): RenderOutput[] {
|
|
238
|
+
return [
|
|
239
|
+
item.icon ? h('text', { content: item.icon, fg: item.tone ? TONE[item.tone] : undefined }) : null,
|
|
240
|
+
h('text', { content: item.label, flex: 1, truncate: 'end' }),
|
|
241
|
+
// The secondary columns keep the row's colour once it is selected;
|
|
242
|
+
// `muted` on a selected background is unreadable.
|
|
243
|
+
//
|
|
244
|
+
// The description yields first, and by a lot: it is the elaboration,
|
|
245
|
+
// and a row reading "brb_fram…" beside a status cut to "waiting on y…"
|
|
246
|
+
// has spent the width on the wrong two things. `meta` gives up none of
|
|
247
|
+
// it - a status is three words at most, and it is the column the row
|
|
248
|
+
// is being scanned for.
|
|
249
|
+
item.description
|
|
250
|
+
? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end', shrink: 8 })
|
|
251
|
+
: null,
|
|
252
|
+
item.meta
|
|
253
|
+
? h('text', { content: item.meta, fg: active ? undefined : 'muted', shrink: 0 })
|
|
254
|
+
: null,
|
|
255
|
+
] as RenderOutput[];
|
|
256
|
+
}
|
|
@@ -51,6 +51,9 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
|
|
|
51
51
|
bullet: theme.glyphs.bulletFilled,
|
|
52
52
|
quoteBar: theme.borderChars().left,
|
|
53
53
|
ruled,
|
|
54
|
+
// How many rules a table gets is the theme's, but *where* they go is a
|
|
55
|
+
// question about rows, so it is answered in the layout rather than here.
|
|
56
|
+
tableRules: theme.tableRules,
|
|
54
57
|
}),
|
|
55
58
|
[given, content, width, theme, ruled],
|
|
56
59
|
);
|
|
@@ -120,6 +123,59 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
|
|
|
120
123
|
continue;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
if (row.kind === 'table') {
|
|
127
|
+
/*
|
|
128
|
+
* A table is ruled even where nothing else is.
|
|
129
|
+
*
|
|
130
|
+
* `theme.border` being `none` is a theme saying "do not box things" -
|
|
131
|
+
* panels, fences, dialogs. It is not saying "do not tell these columns
|
|
132
|
+
* apart": the rules in a table are its structure, not its decoration,
|
|
133
|
+
* and without them it is text that happens to line up, with nothing to
|
|
134
|
+
* say where it ended. So a borderless theme still gets a table, drawn
|
|
135
|
+
* in the plainest set there is.
|
|
136
|
+
*
|
|
137
|
+
* Asked for by name rather than taken from `borderChars()`, which is
|
|
138
|
+
* the same request routed through the terminal's own limits: on a
|
|
139
|
+
* console that cannot draw box characters this comes back as `+`, `-`
|
|
140
|
+
* and `|` rather than as something it would print as a row of boxes.
|
|
141
|
+
*/
|
|
142
|
+
const chars = theme.borderChars(theme.border === 'none' ? 'single' : theme.border);
|
|
143
|
+
const fg = 'borderSubtle';
|
|
144
|
+
|
|
145
|
+
if (row.part !== 'head' && row.part !== 'body') {
|
|
146
|
+
const [left, joint, right] =
|
|
147
|
+
row.part === 'top' ? [chars.topLeft, chars.teeTop, chars.topRight]
|
|
148
|
+
: row.part === 'bottom' ? [chars.bottomLeft, chars.teeBottom, chars.bottomRight]
|
|
149
|
+
: [chars.teeRight, chars.cross, chars.teeLeft];
|
|
150
|
+
const bar = row.part === 'bottom' ? chars.bottom : chars.top;
|
|
151
|
+
// Every column plus the space either side of it, which is what the
|
|
152
|
+
// cell rows spend - an edge measured off the cells alone is an edge
|
|
153
|
+
// two cells short per column.
|
|
154
|
+
out.push(h('text', {
|
|
155
|
+
key,
|
|
156
|
+
content: left
|
|
157
|
+
+ row.widths.map((w) => repeatToWidth(bar, w + 2)).join(joint)
|
|
158
|
+
+ right,
|
|
159
|
+
fg,
|
|
160
|
+
wrap: 'none',
|
|
161
|
+
}));
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The cells arrive padded to their column, so nothing here measures
|
|
167
|
+
// anything: a row is its cells with a rule between each pair.
|
|
168
|
+
out.push(h('box', { key, direction: 'row', overflow: 'hidden' },
|
|
169
|
+
...row.cells.flatMap((cell, c) => [
|
|
170
|
+
h('text', { key: `s${c}`, content: c === 0 ? `${chars.left} ` : ` ${chars.left} `, fg, wrap: 'none' }),
|
|
171
|
+
h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' },
|
|
172
|
+
...runNodes(cell, quiet ? { fg: 'muted' as StyleColor } : {})),
|
|
173
|
+
]),
|
|
174
|
+
h('text', { key: 'end', content: ` ${chars.right}`, fg, wrap: 'none' })));
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
123
179
|
if (row.kind === 'rule') {
|
|
124
180
|
out.push(h('box', { key, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }));
|
|
125
181
|
i++;
|