@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
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
- const entries = (Array.isArray(children) ? children : [children]).filter((c) => c != null);
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
- export interface ListProps extends BoxProps {
28
- items: ListItem[];
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: ListItem): void;
31
- onActivate?(id: string, item: ListItem): void;
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
- export const List = defineComponent<ListProps>('List', (props) => {
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
- const pageSize = viewportRows(props, measured, 10, { requested: visibleRows });
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 = viewportRows(props, measured, items.length, { requested: visibleRows });
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
- item.icon ? h('text', { content: item.icon, fg: item.tone ? TONE[item.tone] : undefined }) : null,
141
- h('text', { content: item.label, flex: 1, truncate: 'end' }),
142
- // The secondary columns keep the row's colour once it is selected;
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++;