@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
@@ -2,6 +2,7 @@ import type { ArgChoice, ArgSpec, BoxProps, CommandDefinition, TextUIApp } from
2
2
  import {
3
3
  defineComponent,
4
4
  h,
5
+ stringWidth,
5
6
  useEffect,
6
7
  useFocusScope,
7
8
  useInput,
@@ -29,10 +30,41 @@ export interface CommandPaletteProps extends BoxProps {
29
30
  onClose?(): void;
30
31
  /** Off makes this a picker: it reports the choice and runs nothing. */
31
32
  execute?: boolean;
32
- /** Group the list by `category`, with a rule between groups. */
33
+ /**
34
+ * Group the list by `category`, with the category named above each group.
35
+ *
36
+ * Only while nothing is typed. A query sorts by relevance, which interleaves
37
+ * the categories - and a heading over one row is not a group.
38
+ */
33
39
  grouped?: boolean;
34
40
  visibleRows?: number;
41
+ /**
42
+ * A fixed width, in cells.
43
+ *
44
+ * Left off, the panel is as wide as its widest row and no wider than
45
+ * `maxWidth` - which is what a list of five short answers wants, and what a
46
+ * list of five sentences needs. A number here is a number: the panel is that
47
+ * wide whether the rows fill it or overflow it.
48
+ */
35
49
  width?: number;
50
+ /**
51
+ * The widest the panel may grow when `width` is left off. 60 by default.
52
+ *
53
+ * There is always a limit: a description is prose, and prose has no width it
54
+ * stops at. Past this the rows truncate, and the row under the cursor slides
55
+ * what it truncated.
56
+ */
57
+ maxWidth?: number;
58
+ /**
59
+ * Where a row's description goes. `inline` right-aligns it beside the label;
60
+ * `below` gives it a line of its own.
61
+ *
62
+ * `below` for a question whose answers differ by a sentence rather than by a
63
+ * word - four approval modes named in two words each are told apart by the
64
+ * line under them, and inline that line is the half that gets truncated.
65
+ * Every row costs two lines, so `visibleRows` buys half as many.
66
+ */
67
+ descriptions?: 'inline' | 'below';
36
68
  /**
37
69
  * Open already drilled into this command's choices.
38
70
  *
@@ -62,7 +94,8 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
62
94
  const runtime = useRuntime();
63
95
  const {
64
96
  commands, placeholder, onRun, onClose, execute = true,
65
- grouped = true, visibleRows = 8, width = 60, openAt, ...rest
97
+ grouped = true, visibleRows = 8, width, maxWidth = 60, openAt,
98
+ descriptions = 'inline', ...rest
66
99
  } = props;
67
100
 
68
101
  const [query, setQuery] = useState('');
@@ -124,17 +157,28 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
124
157
  : matches.map((command, i) => ({
125
158
  id: command.id,
126
159
  label: command.title,
127
- // `badge` when the row has state to report, the category otherwise.
128
- // The icon stays put either way: it is what the row *is*.
129
- description: command.badge ?? command.category,
160
+ // What this row does, or the state it is reporting. The category is
161
+ // not here: it names the *group*, so it is said once above it.
162
+ description: command.badge ?? command.description,
130
163
  icon: command.icon,
131
164
  // A row may stand for a command registered under another id, and the
132
165
  // key a person would press belongs to that one.
133
166
  shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0],
134
167
  // A chevron, from `Menu`, for anything that will ask a question.
135
168
  children: argumentOf(command) ? [] : undefined,
136
- separatorBefore:
137
- grouped && i > 0 && (matches[i - 1] as CommandDefinition).category !== command.category,
169
+ // The heading goes on the first row of each group, including the
170
+ // first - a group with no name over it is the one the reader has to
171
+ // work out from the rows in it.
172
+ //
173
+ // Sorted matches interleave the categories, so a query turns the
174
+ // headings off rather than repeating them: with the rows in relevance
175
+ // order, "Screens" over a single row is noise, and the group it claims
176
+ // to start is one row long.
177
+ ...(grouped && query.trim() === ''
178
+ && (i === 0 || (matches[i - 1] as CommandDefinition).category !== command.category)
179
+ && command.category
180
+ ? { sectionBefore: command.category }
181
+ : {}),
138
182
  }));
139
183
 
140
184
  const back = (): void => {
@@ -193,18 +237,42 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
193
237
  const resolved = typeof arg.choices === 'function' ? arg.choices() : arg.choices ?? [];
194
238
  setPending({ command, arg, collected });
195
239
  setQuery('');
196
- setHighlight(0);
240
+
241
+ /*
242
+ * Open on the answer that is already in force.
243
+ *
244
+ * A question about a setting is nearly always asked in order to change it
245
+ * *from* something, and the row that something is on is where the reader
246
+ * is looking. Starting at the top instead says the first option is the
247
+ * current one, which is wrong on every list where it is not - and it
248
+ * costs an extra press to get back to where you began.
249
+ *
250
+ * `default` is the argument's own word for it, and the same one the row
251
+ * labelled "default" already used.
252
+ */
253
+ const startAt = (list: ArgChoice[]): number => {
254
+ const at = list.findIndex((choice) => choice.value === arg.default);
255
+ return at < 0 ? 0 : at;
256
+ };
257
+
197
258
  if (Array.isArray(resolved)) {
198
259
  setAsking(false);
199
- setChoices(resolved.map(asChoice));
260
+ const list = resolved.map(asChoice);
261
+ setChoices(list);
262
+ setHighlight(startAt(list));
200
263
  return;
201
264
  }
202
265
  setChoices([]);
266
+ setHighlight(0);
203
267
  setAsking(true);
204
268
  // Answered either way: a `choices` function that rejects leaves the panel
205
269
  // saying "nothing to choose", which is true of what it can offer.
206
270
  void resolved
207
- .then((list) => setChoices(list.map(asChoice)))
271
+ .then((list) => {
272
+ const choices = list.map(asChoice);
273
+ setChoices(choices);
274
+ setHighlight(startAt(choices));
275
+ })
208
276
  .catch(() => setChoices([]))
209
277
  .finally(() => setAsking(false));
210
278
  };
@@ -322,12 +390,33 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
322
390
  ?? pending.arg.description ?? `${pending.command.title} needs a ${pending.arg.name}`
323
391
  : highlighted?.description ?? highlighted?.id ?? '';
324
392
 
393
+ /*
394
+ * How wide the panel wants to be.
395
+ *
396
+ * A menu sized to a constant is a menu that is too wide for a list of
397
+ * one-word answers and too narrow for a list of sentences, and it is the
398
+ * same menu either way. So it asks for what it holds - the widest row, plus
399
+ * what the row draws around it - and takes `maxWidth` when that is more than
400
+ * there is any point having.
401
+ *
402
+ * `minWidth` keeps the search field, the hint row and the crumb from being
403
+ * the things that decide it: a question with two short answers still needs
404
+ * somewhere to type and a line saying what the keys do.
405
+ */
406
+ const content = Math.max(
407
+ ...items.map((item) => rowWidth(item, descriptions)),
408
+ ...(pending ? [stringWidth(pending.command.title) + 12] : [stringWidth(placeholder ?? '') + 4]),
409
+ );
410
+
325
411
  return h('box', {
326
412
  role: 'dialog',
327
413
  label: 'Commands',
328
414
  border: theme.border,
329
415
  bg: 'overlay',
330
- width,
416
+ // A stated width is a width. Left off, it fits what it holds.
417
+ ...(width !== undefined
418
+ ? { width }
419
+ : { minWidth: Math.min(28, maxWidth), maxWidth, width: Math.min(content, maxWidth) }),
331
420
  direction: 'column',
332
421
  // A border is a gutter as well as a line. Without one - `paper` sets
333
422
  // `border: 'none'` - the rows run flush to the panel edge and the last
@@ -376,6 +465,9 @@ export const CommandPalette = defineComponent<CommandPaletteProps>('CommandPalet
376
465
  h(Menu, {
377
466
  items,
378
467
  visibleRows,
468
+ // The argument gets the last word: only it knows whether its answers are
469
+ // told apart by a word or by a sentence.
470
+ descriptions: pending?.arg.descriptions ?? descriptions,
379
471
  interactive: false,
380
472
  activeId: rows[index],
381
473
  onSelect: (id: string) => {
@@ -483,3 +575,25 @@ function subsequenceScore(haystack: string, needle: string): number {
483
575
  }
484
576
  return score;
485
577
  }
578
+
579
+ /**
580
+ * The cells one row would like, drawn the way this menu draws it.
581
+ *
582
+ * Mirrors `Menu`'s own layout rather than guessing: the cursor's column and
583
+ * the gap after it, the icon when there is one, the label, and then either the
584
+ * description beside it or a line of its own under it. A description on its
585
+ * own line does not widen the row past its own indent, which is why `below`
586
+ * is the layout a long sentence wants.
587
+ */
588
+ function rowWidth(item: MenuItem, descriptions: 'inline' | 'below'): number {
589
+ // The marker and its gap; a switch column when the menu has one.
590
+ const lead = 2 + (item.checked !== undefined ? 2 : 0)
591
+ + (item.icon ? stringWidth(item.icon) + 1 : 0);
592
+ const label = stringWidth(item.label);
593
+ const trail = (item.shortcut ? stringWidth(item.shortcut) + 1 : 0) + (item.children ? 2 : 0);
594
+ const description = item.description ? stringWidth(item.description) : 0;
595
+
596
+ return descriptions === 'below'
597
+ ? Math.max(lead + label + trail, lead + description)
598
+ : lead + label + (description > 0 ? description + 2 : 0) + trail;
599
+ }
@@ -19,6 +19,8 @@ export const WorkbenchShell = defineComponent<ShellProps>('WorkbenchShell', (pro
19
19
  const sidebar = useSurfaceMounted('sidebar');
20
20
  const aside = useSurfaceMounted('aside');
21
21
  const narrow = size.width < 90;
22
+ const showSidebar = Boolean(sidebar) && !sidebarCollapsed && !narrow;
23
+ const showAside = Boolean(aside) && asideVisible && !narrow;
22
24
 
23
25
  return h('box', {
24
26
  direction: 'column',
@@ -34,7 +36,7 @@ export const WorkbenchShell = defineComponent<ShellProps>('WorkbenchShell', (pro
34
36
  h('box', { direction: 'row', flex: 1 },
35
37
  h(SurfaceArea, { surface: 'rail' }),
36
38
 
37
- sidebar && !sidebarCollapsed && !narrow
39
+ showSidebar
38
40
  ? h('box', {
39
41
  width: 24,
40
42
  border: { style: theme.border, sides: { right: true } },
@@ -43,11 +45,22 @@ export const WorkbenchShell = defineComponent<ShellProps>('WorkbenchShell', (pro
43
45
  }, h(SurfaceArea, { surface: 'sidebar', flex: 1 }))
44
46
  : null,
45
47
 
46
- h('box', { flex: 1, direction: 'column', padding: { left: 1 } },
48
+ // A gutter separates main from the pane beside it, so it belongs on the
49
+ // sides that have one. Applied unconditionally it insets every screen by
50
+ // a cell on the left and nothing on the right - hidden under a theme
51
+ // that draws a frame, and plainly lopsided under one that does not.
52
+ h('box', {
53
+ flex: 1,
54
+ direction: 'column',
55
+ padding: {
56
+ ...(showSidebar ? { left: 1 } : {}),
57
+ ...(showAside ? { right: 1 } : {}),
58
+ },
59
+ },
47
60
  h(SurfaceArea, { surface: 'main', flex: 1 }),
48
61
  h(SurfaceArea, { surface: 'panel' })),
49
62
 
50
- aside && asideVisible && !narrow
63
+ showAside
51
64
  ? h('box', {
52
65
  width: 30,
53
66
  border: { style: theme.border, sides: { left: true } },