@textui/widgets 0.7.0 → 0.9.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 (86) hide show
  1. package/dist/control/text-area.d.ts +2 -0
  2. package/dist/control/text-area.d.ts.map +1 -1
  3. package/dist/control/text-area.js +16 -8
  4. package/dist/control/text-input.d.ts.map +1 -1
  5. package/dist/control/text-input.js +6 -0
  6. package/dist/data/code-viewer.d.ts +2 -0
  7. package/dist/data/code-viewer.d.ts.map +1 -1
  8. package/dist/data/code-viewer.js +9 -1
  9. package/dist/data/index.d.ts +9 -0
  10. package/dist/data/index.d.ts.map +1 -1
  11. package/dist/data/index.js +8 -2
  12. package/dist/data/list.d.ts +9 -0
  13. package/dist/data/list.d.ts.map +1 -1
  14. package/dist/data/list.js +20 -3
  15. package/dist/data/markdown-view.d.ts.map +1 -1
  16. package/dist/data/markdown-view.js +4 -1
  17. package/dist/data/pagination.d.ts.map +1 -1
  18. package/dist/data/pagination.js +4 -1
  19. package/dist/data/tree.d.ts +2 -0
  20. package/dist/data/tree.d.ts.map +1 -1
  21. package/dist/data/tree.js +8 -2
  22. package/dist/display/card.d.ts.map +1 -1
  23. package/dist/display/card.js +12 -0
  24. package/dist/display/color-text.d.ts +13 -1
  25. package/dist/display/color-text.d.ts.map +1 -1
  26. package/dist/display/color-text.js +28 -6
  27. package/dist/display/font-text.d.ts +72 -0
  28. package/dist/display/font-text.d.ts.map +1 -0
  29. package/dist/display/font-text.js +35 -0
  30. package/dist/display/fonts.d.ts +138 -0
  31. package/dist/display/fonts.d.ts.map +1 -0
  32. package/dist/display/fonts.js +893 -0
  33. package/dist/display/gard.d.ts +26 -0
  34. package/dist/display/gard.d.ts.map +1 -0
  35. package/dist/display/gard.js +479 -0
  36. package/dist/display/index.d.ts +3 -0
  37. package/dist/display/index.d.ts.map +1 -1
  38. package/dist/display/index.js +7 -0
  39. package/dist/display/key-value.d.ts +25 -5
  40. package/dist/display/key-value.d.ts.map +1 -1
  41. package/dist/display/key-value.js +13 -3
  42. package/dist/display/marquee.d.ts.map +1 -1
  43. package/dist/display/marquee.js +16 -3
  44. package/dist/display/pagga.d.ts +29 -0
  45. package/dist/display/pagga.d.ts.map +1 -0
  46. package/dist/display/pagga.js +101 -0
  47. package/dist/display/pattern.d.ts +117 -0
  48. package/dist/display/pattern.d.ts.map +1 -0
  49. package/dist/display/pattern.js +257 -0
  50. package/dist/display/spinner.d.ts +2 -0
  51. package/dist/display/spinner.d.ts.map +1 -1
  52. package/dist/display/spinner.js +3 -2
  53. package/dist/navigation/menu.d.ts +11 -0
  54. package/dist/navigation/menu.d.ts.map +1 -1
  55. package/dist/navigation/menu.js +22 -9
  56. package/dist/navigation/tabs.d.ts +2 -0
  57. package/dist/navigation/tabs.d.ts.map +1 -1
  58. package/dist/navigation/tabs.js +12 -2
  59. package/dist/overlay/command-palette.d.ts.map +1 -1
  60. package/dist/overlay/command-palette.js +18 -6
  61. package/dist/shells/workbench-shell.d.ts.map +1 -1
  62. package/dist/shells/workbench-shell.js +5 -1
  63. package/package.json +2 -2
  64. package/src/control/text-area.ts +18 -7
  65. package/src/control/text-input.ts +6 -0
  66. package/src/data/code-viewer.ts +10 -1
  67. package/src/data/index.ts +9 -2
  68. package/src/data/list.ts +21 -3
  69. package/src/data/markdown-view.ts +7 -4
  70. package/src/data/pagination.ts +4 -1
  71. package/src/data/tree.ts +9 -2
  72. package/src/display/card.ts +12 -0
  73. package/src/display/color-text.ts +33 -10
  74. package/src/display/font-text.tsx +94 -0
  75. package/src/display/fonts.ts +990 -0
  76. package/src/display/gard.ts +475 -0
  77. package/src/display/index.ts +7 -0
  78. package/src/display/key-value.ts +44 -6
  79. package/src/display/marquee.ts +15 -3
  80. package/src/display/pagga.ts +102 -0
  81. package/src/display/pattern.tsx +427 -0
  82. package/src/display/spinner.ts +5 -2
  83. package/src/navigation/menu.ts +32 -9
  84. package/src/navigation/tabs.ts +13 -2
  85. package/src/overlay/command-palette.ts +13 -2
  86. package/src/shells/workbench-shell.ts +5 -1
@@ -1,10 +1,12 @@
1
1
  import { defineComponent, h, stringWidth, useFocus, useI18n, useInput, useState, useTheme } from '@textui/core';
2
2
  import { Marquee } from '../display/index.js';
3
3
  import { TONE } from '../tone.js';
4
+ /** The name a theme styles a menu row under. See `LIST`. */
5
+ export const MENU = 'Menu';
4
6
  export const Menu = defineComponent('Menu', (props) => {
5
7
  const theme = useTheme();
6
8
  const i18n = useI18n();
7
- const { items, onSelect, visibleRows, activeId, autoFocus, interactive = true, descriptions = 'inline', ...rest } = props;
9
+ const { items, onSelect, visibleRows, activeId, autoFocus, interactive = true, descriptions = 'inline', noMatch, ...rest } = props;
8
10
  const focus = useFocus({ autoFocus, disabled: !interactive });
9
11
  const selectable = items.filter((i) => !i.disabled);
10
12
  const [internalHighlight, setHighlight] = useState(Math.max(0, items.findIndex((i) => i.id === activeId)));
@@ -58,14 +60,19 @@ export const Menu = defineComponent('Menu', (props) => {
58
60
  // The description yields first, and by a lot. It is the elaboration;
59
61
  // the label is the thing being chosen, and a row reading "Accept ed…"
60
62
  // beside a full sentence has given up the wrong half.
63
+ //
64
+ // On the row under the cursor the foreground is `undefined`, not a
65
+ // token: what is written on a selection is the theme's, and a hard
66
+ // `onSelected` here would be a second answer to the same question that
67
+ // a theme restating `Menu.focused.fg` could not reach.
61
68
  item.description && !below
62
69
  ? h(Marquee, {
63
70
  content: item.description,
64
71
  active,
65
- fg: active ? 'inverted' : 'muted',
72
+ fg: active ? undefined : 'muted',
66
73
  shrink: 8,
67
74
  })
68
- : null, item.shortcut ? h('text', { content: item.shortcut, fg: active ? 'inverted' : 'subtle' }) : null,
75
+ : null, item.shortcut ? h('text', { content: item.shortcut, fg: active ? undefined : 'subtle' }) : null,
69
76
  // Present but empty means "this opens something, contents unknown" -
70
77
  // which is what the command palette knows about an argument whose
71
78
  // choices it has not resolved yet.
@@ -74,12 +81,18 @@ export const Menu = defineComponent('Menu', (props) => {
74
81
  key: item.id,
75
82
  role: 'menuitem',
76
83
  label: item.label,
84
+ styleAs: MENU,
77
85
  selected: active,
86
+ // The keyboard is the menu's, so the row is told - which is what lets
87
+ // `Menu.selected` be the dimmer fill a menu shows when something else
88
+ // has taken the keyboard, and `Menu.focused` the one it shows while
89
+ // the reader is moving through it.
90
+ focused: active && focus.focused,
78
91
  direction: 'column',
79
92
  // One background over both lines: a highlight that stopped after the
80
- // label would split the row it is highlighting in two.
81
- bg: active ? 'selected' : undefined,
82
- fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined,
93
+ // label would split the row it is highlighting in two. What it is
94
+ // filled with is the theme's to say.
95
+ fg: item.disabled ? 'disabled' : active ? undefined : item.tone ? TONE[item.tone] : undefined,
83
96
  onClick: () => { if (!item.disabled)
84
97
  onSelect?.(item.id, item); },
85
98
  }, head,
@@ -94,7 +107,7 @@ export const Menu = defineComponent('Menu', (props) => {
94
107
  h(Marquee, {
95
108
  content: item.description,
96
109
  active,
97
- fg: active ? 'inverted' : 'muted',
110
+ fg: active ? undefined : 'muted',
98
111
  flex: 1,
99
112
  }))
100
113
  : null);
@@ -110,8 +123,8 @@ export const Menu = defineComponent('Menu', (props) => {
110
123
  return item.separatorBefore
111
124
  ? [h('box', { key: `${item.id}-sep`, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }), row]
112
125
  : [row];
113
- }), selectable.length === 0
114
- ? h('text', { content: ` ${i18n.t('textui.menu.noMatch', undefined, 'no matches')}`, fg: 'subtle' })
126
+ }), selectable.length === 0 && noMatch !== false
127
+ ? h('text', { content: ` ${noMatch ?? i18n.t('textui.menu.noMatch', undefined, 'no matches')}`, fg: 'subtle' })
115
128
  : null);
116
129
  });
117
130
  /**
@@ -16,5 +16,7 @@ export interface TabsProps extends BoxProps {
16
16
  /** Take focus on mount, so the keyboard has somewhere to be. */
17
17
  autoFocus?: boolean;
18
18
  }
19
+ /** The name a theme styles a tab under. See `LIST`. */
20
+ export declare const TABS = "Tabs";
19
21
  export declare const Tabs: (props: TabsProps) => import("@textui/core").RenderOutput;
20
22
  //# sourceMappingURL=tabs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/navigation/tabs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,wDAAwD;IACxD,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,2DAiDf,CAAC"}
1
+ {"version":3,"file":"tabs.d.ts","sourceRoot":"","sources":["../../src/navigation/tabs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAI7C,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ;IACzC,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,wDAAwD;IACxD,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,uDAAuD;AACvD,eAAO,MAAM,IAAI,SAAS,CAAC;AAE3B,eAAO,MAAM,IAAI,2DAyDf,CAAC"}
@@ -1,4 +1,6 @@
1
1
  import { defineComponent, h, useFocus, useInput } from '@textui/core';
2
+ /** The name a theme styles a tab under. See `LIST`. */
3
+ export const TABS = 'Tabs';
2
4
  export const Tabs = defineComponent('Tabs', (props) => {
3
5
  const { items, activeId, onChange, variant = 'underline', separator, autoFocus, ...rest } = props;
4
6
  const focus = useFocus({ autoFocus });
@@ -19,12 +21,20 @@ export const Tabs = defineComponent('Tabs', (props) => {
19
21
  key: item.id,
20
22
  role: 'tab',
21
23
  label: item.label,
24
+ styleAs: TABS,
25
+ // Carried so the theme can tell a filled tab from an unfilled one: a
26
+ // solid tab is `Tabs.solid.selected`, an underline one is not filled
27
+ // at all. See `Tabs` in the built-in themes.
28
+ variant,
29
+ // A tab is open, not selected, and it says so with one state. There is
30
+ // no second name for it because there is nothing to dim: the document
31
+ // a tab names is open whether or not the strip has the keyboard, and
32
+ // dimming the tab on losing focus would claim none is.
22
33
  selected: active,
23
34
  direction: 'row',
24
35
  gap: 1,
25
36
  padding: variant === 'solid' ? [0, 1] : 0,
26
- bg: variant === 'solid' && active ? 'selected' : undefined,
27
- fg: item.disabled ? 'disabled' : active ? (variant === 'solid' ? 'inverted' : 'accent') : 'muted',
37
+ fg: item.disabled ? 'disabled' : active ? (variant === 'solid' ? undefined : 'accent') : 'muted',
28
38
  bold: active,
29
39
  underline: variant === 'underline' && active,
30
40
  onClick: () => { if (!item.disabled)
@@ -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;AAkB/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;AAyBD,eAAO,MAAM,cAAc,qEAsezB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AA2DD,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;AAkB/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;AAyBD,eAAO,MAAM,cAAc,qEAifzB,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACtC,OAAO,GAAG,SAAS,CAKrB;AA2DD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAehG"}
@@ -323,11 +323,16 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
323
323
  // It is the one line in the panel that wraps rather than truncates,
324
324
  // because it is the one place a sentence can be read whole.
325
325
  const chosen = pending ? offered[index] : undefined;
326
- const detail = pending
327
- ? chosen?.description
328
- ?? pending.arg.description
329
- ?? i18n.t('textui.palette.needs', { command: pending.command.title, arg: pending.arg.name }, '{command} needs a {arg}')
330
- : highlighted?.description ?? highlighted?.id ?? '';
326
+ // Nothing selectable is nothing to describe: the line under the list would
327
+ // otherwise keep the last answer's sentence, or repeat the question the row
328
+ // above has already asked.
329
+ const detail = rows.length === 0
330
+ ? ''
331
+ : pending
332
+ ? chosen?.description
333
+ ?? pending.arg.description
334
+ ?? i18n.t('textui.palette.needs', { command: pending.command.title, arg: pending.arg.name }, '{command} needs a {arg}')
335
+ : highlighted?.description ?? highlighted?.id ?? '';
331
336
  /*
332
337
  * How wide the panel wants to be.
333
338
  *
@@ -419,6 +424,11 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
419
424
  descriptions: pending?.arg.descriptions ?? descriptions,
420
425
  interactive: false,
421
426
  activeId: rows[index],
427
+ // When the answer is a fixed set, the row above already says which kind
428
+ // of nothing this is and this would be the second line. Searching the
429
+ // command list has no such row, and there the menu's own line is the one
430
+ // that says it.
431
+ noMatch: pending ? false : undefined,
422
432
  onSelect: (id) => {
423
433
  const at = rows.indexOf(id);
424
434
  if (at >= 0)
@@ -428,7 +438,9 @@ export const CommandPalette = defineComponent('CommandPalette', (props) => {
428
438
  }), h('box', { height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }),
429
439
  // What the highlighted row actually is, and how to move around. A palette
430
440
  // that shows only titles makes you run something to find out what it does.
431
- h('box', { direction: 'row', gap: 1, align: 'start' }, h('text', { content: detail, fg: 'muted', flex: 1, wrap: 'word' }), h('text', { content: `${rows.length}`, fg: 'subtle' })), h('box', { direction: 'row', gap: 1 }, h('text', {
441
+ h('box', { direction: 'row', gap: 1, align: 'start' }, h('text', { content: detail, fg: 'muted', flex: 1, wrap: 'word' }),
442
+ // A zero beside an empty line is a number with nothing to count.
443
+ rows.length > 0 ? h('text', { content: `${rows.length}`, fg: 'subtle' }) : null), h('box', { direction: 'row', gap: 1 }, h('text', {
432
444
  content: pending
433
445
  ? (pending.arg.choices === undefined
434
446
  ? hint(theme, [
@@ -1 +1 @@
1
- {"version":3,"file":"workbench-shell.d.ts","sourceRoot":"","sources":["../../src/shells/workbench-shell.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,iFAmEzB,CAAC"}
1
+ {"version":3,"file":"workbench-shell.d.ts","sourceRoot":"","sources":["../../src/shells/workbench-shell.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,iFAuEzB,CAAC"}
@@ -25,7 +25,11 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (props) => {
25
25
  height: '100%',
26
26
  bg: 'canvas',
27
27
  fg: 'text',
28
- border: theme.border,
28
+ // The frame is chrome, not text. A box that states an `fg` draws its
29
+ // border in it - which is right for `<box fg="danger" border="single">`
30
+ // and wrong here, where the `fg` is the application's text colour: it made
31
+ // every screen's outermost edge as bright as the words inside it.
32
+ border: { style: theme.border, color: 'border' },
29
33
  ...props,
30
34
  }, h(SurfaceArea, { surface: 'header' }), h('box', { direction: 'row', flex: 1 }, h(SurfaceArea, { surface: 'rail' }), showSidebar
31
35
  ? h('box', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textui/widgets",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "The TextUI component catalog - layout, display, controls, data, overlays and charts",
5
5
  "keywords": [
6
6
  "terminal",
@@ -46,7 +46,7 @@
46
46
  "test": "vitest run"
47
47
  },
48
48
  "dependencies": {
49
- "@textui/core": "^0.7.0"
49
+ "@textui/core": "^0.9.0"
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"
@@ -1,8 +1,10 @@
1
1
  import type { BoxProps, KeyEvent, MouseEvent, SemanticVariant, Style } from '@textui/core';
2
2
  import {
3
+ NO_INTERACTION,
3
4
  defineComponent,
4
5
  graphemes,
5
6
  h,
7
+ stateVariants,
6
8
  stringWidth,
7
9
  useClipboard,
8
10
  useEffect,
@@ -122,6 +124,9 @@ export interface TextAreaProps extends BoxProps {
122
124
  * is what typing is. That single fact is why an application with one of these
123
125
  * cannot have single-letter global commands, and why it does not need to.
124
126
  */
127
+ /** The name a theme styles this field's selection under. See `LIST`. */
128
+ export const TEXT_AREA = 'TextArea';
129
+
125
130
  export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
126
131
  const {
127
132
  value, onChange, onSubmit, onCancel, onOverflow, onEdge, placeholder, maxRows = 6,
@@ -222,14 +227,20 @@ export const TextArea = defineComponent<TextAreaProps>('TextArea', (props) => {
222
227
  /**
223
228
  * How a selected cell is drawn.
224
229
  *
225
- * The same pair a selected list row uses, and dimmer once the field loses
226
- * the keyboard: a selection left visible in an unfocused field says what is
227
- * on the clipboard, and saying it as loudly as the live one would put two
228
- * selections on the screen.
230
+ * The theme's to say, and the same pair a selected list row uses: the
231
+ * `TextArea.focused` fill while the field has the keyboard and the dimmer
232
+ * `TextArea.selected` one once it does not, because a selection left
233
+ * visible in an unfocused field says what is on the clipboard, and saying it
234
+ * as loudly as the live one would put two selections on the screen.
235
+ *
236
+ * Asked for here rather than read off a node, because the field's selection
237
+ * is painted across cells that are not themselves selected nodes.
229
238
  */
230
- const selectionStyle: Style = focus.focused
231
- ? { bg: 'selected', fg: 'inverted' }
232
- : { bg: 'active' };
239
+ const selectionStyle: Style = theme.styleFor(TEXT_AREA, stateVariants({
240
+ ...NO_INTERACTION,
241
+ selected: true,
242
+ focused: focus.focused,
243
+ }));
233
244
 
234
245
  const before = chars.slice(0, position).join('');
235
246
  const lines = value === '' ? [''] : value.split('\n');
@@ -182,6 +182,12 @@ export const TextInput = defineComponent<TextInputProps>('TextInput', (props) =>
182
182
  : sliceColumns(shown, start, field),
183
183
  fg: empty ? 'subtle' : disabled ? 'disabled' : undefined,
184
184
  flex: 1,
185
+ // The name a theme styles this field's text under. Without it a theme
186
+ // could colour a list row and not the field a person types in - and the
187
+ // palette's search box, which is this component, could not be coloured
188
+ // at all. The placeholder and the disabled case state their own `fg`, so
189
+ // they keep the colours they had.
190
+ styleAs: 'TextInput',
185
191
  }),
186
192
  );
187
193
  });
@@ -92,6 +92,9 @@ export const HORIZONTAL_STEP = 4;
92
92
  * around it. And it colours by asking the registry for a highlighter, so a new
93
93
  * file type arrives coloured without this component learning what it is.
94
94
  */
95
+ /** The name a theme styles a code row under. See `LIST`. */
96
+ export const CODE_VIEWER = 'CodeViewer';
97
+
95
98
  export const CodeViewer = defineComponent<CodeViewerProps>('CodeViewer', (props) => {
96
99
  const {
97
100
  content, lineNumbers = true, startLine = 1, visibleRows, highlight = [],
@@ -242,7 +245,13 @@ export const CodeViewer = defineComponent<CodeViewerProps>('CodeViewer', (props)
242
245
  key: number,
243
246
  direction: 'row',
244
247
  height: 1,
245
- bg: marked ? 'active' : onCaret ? 'hover' : undefined,
248
+ styleAs: CODE_VIEWER,
249
+ // A marked line is a selection; the caret line is that selection with
250
+ // the keyboard on it. Both fills are the theme's - `CodeViewer.selected`
251
+ // and `CodeViewer.focused` - and both carry the colour that reads on
252
+ // the fill, so a token inside the row that states its own keeps it.
253
+ selected: marked,
254
+ focused: onCaret,
246
255
  },
247
256
  lineNumbers
248
257
  ? h('text', {
package/src/data/index.ts CHANGED
@@ -78,6 +78,9 @@ export interface TableProps<T extends object = Record<string, unknown>> extends
78
78
  * table narrows it drops the lowest-priority columns rather than squeezing
79
79
  * every column until none of them is readable.
80
80
  */
81
+ /** The name a theme styles a table row under. See `LIST` in `./list.js`. */
82
+ export const TABLE = 'Table';
83
+
81
84
  const TableImpl = defineComponent<TableProps<Record<string, unknown>>>('Table', (props) => {
82
85
  const theme = useTheme();
83
86
  const i18n = useI18n();
@@ -206,11 +209,15 @@ const TableImpl = defineComponent<TableProps<Record<string, unknown>>>('Table',
206
209
  return h('box', {
207
210
  key,
208
211
  role: 'row',
212
+ styleAs: TABLE,
209
213
  selected: active,
214
+ // The keyboard is the table's; the row is told which row it lands on.
215
+ focused: active && focus.focused,
210
216
  direction: 'row',
211
217
  gap,
212
- bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
213
- fg: active && focus.focused ? 'inverted' : undefined,
218
+ // What the row is filled with is the theme's to say - `Table.selected`
219
+ // for the current row and `Table.focused` over it while the table has
220
+ // the keyboard - as it is for a list, a tree and a menu.
214
221
  onClick: () => {
215
222
  if (selectedKey === undefined) setInternalKey(key);
216
223
  onSelect?.(key, row);
package/src/data/list.ts CHANGED
@@ -25,6 +25,16 @@ export interface ListItem {
25
25
  disabled?: boolean;
26
26
  }
27
27
 
28
+ /**
29
+ * The name a theme styles a row under.
30
+ *
31
+ * A row is a plain `box`, so without this `components.List` in a theme is a
32
+ * key nothing reads. Stated once here because the row, the tree, the table
33
+ * and the menu all fill the same two colours and a theme author comparing
34
+ * them should be reading the same four names.
35
+ */
36
+ export const LIST = 'List';
37
+
28
38
  /** How a row stands at the moment it is asked to draw itself. */
29
39
  export interface ListItemState {
30
40
  /** The row the selection is on. */
@@ -199,14 +209,22 @@ function ListView<T extends ListItem>(props: ListProps<T>): RenderOutput {
199
209
  key: item.id,
200
210
  role: 'listitem',
201
211
  label: item.label,
212
+ styleAs: LIST,
202
213
  selected: active,
214
+ // The keyboard is the list's, not the row's, so the row is told. It is
215
+ // the difference between "the current row" and "the current row you
216
+ // can type at", and a theme states the two fills separately.
217
+ focused: active && focus.focused,
203
218
  direction: 'row',
204
219
  gap: 1,
205
220
  // One background over the whole row, however many lines it draws: a
206
221
  // highlight that stopped after the first would split the row it is
207
- // highlighting in two.
208
- bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
209
- fg: item.disabled ? 'disabled' : active && focus.focused ? 'inverted' : undefined,
222
+ // highlighting in two. What it is filled with is the theme's to say -
223
+ // `List.selected` for the current row, `List.focused` over it while
224
+ // the list has the keyboard - and a background always comes with the
225
+ // colour that reads on it, which is why that pairing lives in the
226
+ // theme rather than here.
227
+ fg: item.disabled ? 'disabled' : undefined,
210
228
  onClick: () => {
211
229
  if (item.disabled) return;
212
230
  if (selectedId === undefined) setInternalId(item.id);
@@ -223,10 +223,13 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
223
223
  return h('box', { role: 'document', direction: 'column', ...rest },
224
224
  ...out,
225
225
  hidden > 0
226
- ? h('text', { content: i18n.t(
227
- 'textui.markdown.moreLines',
228
- { glyph: theme.glyphs.ellipsis, count: hidden },
229
- '{glyph} {count} more lines',
226
+ ? h('text', { content: i18n.plural(
227
+ hidden,
228
+ {
229
+ one: i18n.t('textui.markdown.moreLines.one', undefined, '{glyph} {count} more line'),
230
+ other: i18n.t('textui.markdown.moreLines.other', undefined, '{glyph} {count} more lines'),
231
+ },
232
+ { glyph: theme.glyphs.ellipsis },
230
233
  ), fg: 'subtle' })
231
234
  : null,
232
235
  );
@@ -27,6 +27,9 @@ export const Pagination = defineComponent<PaginationProps>('Pagination', (props)
27
27
  h('text', { content: theme.glyphs.chevronLeft, fg: page > 1 ? 'accent' : 'disabled' }),
28
28
  h('text', { content: `${page} / ${pageCount}`, bold: focus.focused }),
29
29
  h('text', { content: theme.glyphs.chevronRight, fg: page < pageCount ? 'accent' : 'disabled' }),
30
- total !== undefined ? h('text', { content: i18n.t('textui.pagination.total', { total }, '{total} items'), fg: 'muted' }) : null,
30
+ total !== undefined ? h('text', { content: i18n.plural(total, {
31
+ one: i18n.t('textui.pagination.total.one', undefined, '{count} item'),
32
+ other: i18n.t('textui.pagination.total.other', undefined, '{count} items'),
33
+ }), fg: 'muted' }) : null,
31
34
  );
32
35
  });
package/src/data/tree.ts CHANGED
@@ -12,6 +12,9 @@ import { usePanelState } from '../panel/index.js';
12
12
  import { TONE } from '../tone.js';
13
13
  import { viewportRows } from '../viewport.js';
14
14
 
15
+ /** The name a theme styles a tree row under. See `LIST`. */
16
+ export const TREE = 'Tree';
17
+
15
18
  export interface TreeNode {
16
19
  id: string;
17
20
  label: string;
@@ -187,11 +190,15 @@ export const Tree = defineComponent<TreeProps>('Tree', (props) => {
187
190
  key: row.node.id,
188
191
  role: 'treeitem',
189
192
  label: row.node.label,
193
+ styleAs: TREE,
190
194
  selected: active,
195
+ // The keyboard is the tree's; the row is told which row it lands on.
196
+ focused: active && focus.focused,
191
197
  direction: 'row',
192
198
  gap: 1,
193
- bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
194
- fg: active && focus.focused ? 'inverted' : undefined,
199
+ // What the row is filled with is the theme's to say - `Tree.selected`
200
+ // for the current row and `Tree.focused` over it while the tree has
201
+ // the keyboard - as it is for a list, a table and a menu.
195
202
  onClick: () => {
196
203
  if (selectedId === undefined) setInternalSelected(row.node.id);
197
204
  onSelect?.(row.node.id, row.node);
@@ -12,6 +12,18 @@ export const Card = defineComponent<CardProps>('Card', (props) => {
12
12
  const { title, subtitle, children, ...rest } = props;
13
13
  return h('box', {
14
14
  border: theme.border,
15
+ /*
16
+ * A card is a surface, so it states a background and is opaque.
17
+ *
18
+ * A bordered box that states none is a frame around whatever is already
19
+ * there, and what is already there can be a pattern, a scrim or a
20
+ * neighbouring block - a card dropped on a pattern showed the pattern
21
+ * through its own interior, which reads as a hole rather than as something
22
+ * laid on top. `Dialog` and `CommandPalette` have always stated `overlay`
23
+ * for the same reason; this is the page-level one. `...rest` still wins, so
24
+ * a caller that wants a different colour names it.
25
+ */
26
+ bg: 'surface',
15
27
  padding: theme.density === 'compact' ? 0 : [0, 1],
16
28
  direction: 'column',
17
29
  title,
@@ -1,5 +1,5 @@
1
1
  import type {
2
- CellStyle, Color, InteractionState, PaintSurface,
2
+ CellStyle, Color, InteractionState, PaintSurface, Style, StyleInput,
3
3
  RenderContext, StyleColor, TextProps, TextWrap,
4
4
  } from '@textui/core';
5
5
  import {
@@ -177,15 +177,9 @@ export const ColorText = defineComponent<ColorTextProps>('ColorText', (props) =>
177
177
 
178
178
  // The component's own style, resolved for the state it is in. Not the
179
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
- };
180
+ const ownProps = props as Record<string, unknown>;
181
+ const own = inkOwnStyle(ownProps, ctx);
182
+ const rest = inkCellStyle(own, ctx, props.link);
189
183
 
190
184
  const cut = props.truncate ?? truncateSideOf(wrap) ?? 'end';
191
185
  const dots = ellipsis ?? ctx.theme.glyphs.ellipsis;
@@ -256,11 +250,40 @@ export function blend(a: Color, b: Color, t: number): Color {
256
250
  * Exported so a component that paints its own cells - a chart, a viewer with a
257
251
  * heat column - can take an `Ink` and mean the same thing by it.
258
252
  */
253
+ /**
254
+ * A component's own style, resolved for the state it is in.
255
+ *
256
+ * A canvas is not told what it was nested in, so a component that paints its
257
+ * own cells cannot inherit the way a `text` does: it resolves the props it was
258
+ * given against the theme, and hands the result to every cell the ink declines.
259
+ * Exported beside `painterOf` so a second painter means the same thing by it -
260
+ * `ColorText` and `Pattern` both paint their own cells and read the same way.
261
+ */
262
+ export function inkOwnStyle(props: Record<string, unknown>, ctx: RenderContext): Style {
263
+ return mergeStyles(
264
+ styleFromProps(props),
265
+ flattenStyleInput(props.style as StyleInput | undefined, stateOf(ctx)),
266
+ );
267
+ }
268
+
269
+ /** The cell style a canvas painter starts from, given that own style. */
270
+ export function inkCellStyle(own: Style, ctx: RenderContext, link?: unknown): CellStyle {
271
+ return {
272
+ fg: resolveColor(own.fg, ctx.theme, 'default'),
273
+ attrs: attrsFromStyle(own),
274
+ link: typeof link === 'string' ? link : undefined,
275
+ };
276
+ }
277
+
259
278
  export function painterOf(
260
279
  ink: Ink | undefined,
261
280
  ctx: RenderContext,
262
281
  ): (cell: InkCell) => CellStyle | undefined {
263
282
  if (ink === undefined) return () => undefined;
283
+ // A theme with no colour has no inks, and this is not only faster: a ramp
284
+ // whose stops all resolve to the terminal's own still has to be *mixed*, and
285
+ // mixing "no colour" with itself lands on black - which is a colour.
286
+ if (ctx.theme.monochrome) return () => undefined;
264
287
 
265
288
  if (typeof ink === 'function') {
266
289
  return (cell) => styleOf(ink(cell, ctx), ctx);
@@ -0,0 +1,94 @@
1
+ import type { BoxProps } from '@textui/core';
2
+ import { defineComponent, h, useCapabilities, useMeasure, useTheme } from '@textui/core';
3
+ import { banner, DEFAULT_FONT, fontAt, inkGlyphs } from './fonts.js';
4
+ import type { Font } from './fonts.js';
5
+
6
+ /**
7
+ * `wrap` is not `BoxProps.wrap`.
8
+ *
9
+ * On a `text` it means where a line of *characters* is cut, and the whole point
10
+ * of this component is that the block it draws is not a line of characters: its
11
+ * rows are the rows of the letters, so cutting one at column sixty cuts every
12
+ * letter in it in half. `wrap` here means the one thing that can be done
13
+ * instead - break the *text* between its words, measured in the font - and it
14
+ * has no `char`, because a banner broken mid-word is not a banner. The drawn
15
+ * rows are handed to the `text` node as `wrap: 'none'` always; a caller cannot
16
+ * reach that.
17
+ */
18
+ export interface FontTextProps extends Omit<BoxProps, 'wrap'> {
19
+ /** The text to draw. A newline is a line, and each is a line of banner. */
20
+ content: string;
21
+ /**
22
+ * The font to draw it in. The shipped `block` table by default.
23
+ *
24
+ * A font may name a substitute for a terminal that cannot draw its glyphs -
25
+ * `fallback` - and that is used here in place of it. There is no guessing one
26
+ * for a table the library has never seen, which is why the font says.
27
+ */
28
+ font?: Font;
29
+ /**
30
+ * Break the text between its words so the drawn banner fits the box.
31
+ *
32
+ * `'none'` - the default - draws the text as it was written, however wide
33
+ * that is: a banner is usually a size somebody picked, and at a width the
34
+ * caller did not intend, letters that reflow are worse than letters that
35
+ * overflow. `'word'` asks the box for its width and breaks the text to it,
36
+ * words first and a word too wide for a line of its own spent a character at
37
+ * a time, which is the only place a letter boundary is ever crossed.
38
+ *
39
+ * It measures the box, so the first frame - which has no width yet - draws
40
+ * one unbroken line, exactly as `'none'` would.
41
+ */
42
+ wrap?: 'none' | 'word';
43
+ /**
44
+ * Blank rows between one line of the block and the next. One by default.
45
+ *
46
+ * Not `gap`, which every box already has and means the space between its
47
+ * *children*: this is the space between the rows of one drawing. `0` butts
48
+ * the lines together, which for a font with a ground of its own - `pagga`'s
49
+ * `░`, `tmplt`'s rules - merges them into one texture; a larger number is air
50
+ * between them, and reads as two lines rather than as one.
51
+ *
52
+ * A newline in `content` is a line, and so is a wrap when `wrap: 'word'` is
53
+ * breaking the text: the gap is the same one either way.
54
+ */
55
+ lineGap?: number;
56
+ }
57
+
58
+ /**
59
+ * Text as block letters.
60
+ *
61
+ * The shape only: a string in, the same string drawn in the font's glyphs out,
62
+ * as a `text` node that inherits its colour like any other. Colouring the
63
+ * letters cell by cell - a ramp across a banner, a palette down it - is
64
+ * [`ColorText`](color-text.md), and handing it this output is the pair the two
65
+ * were split for.
66
+ *
67
+ * The letters are laid out by [`banner`](fonts.ts), which measures in the font
68
+ * rather than in cells, so the tracking is the font's and a glyph is
69
+ * proportional to what it actually draws. A character the font has no glyph for
70
+ * becomes a word space, so a missing letter shows as a gap rather than quietly
71
+ * closing up, and the placeholders a table is written in - `#`, `%`, `^`, `v` -
72
+ * are filled from the theme's own glyphs, which is what keeps a banner legible
73
+ * on a terminal that can only do ascii.
74
+ */
75
+ export const FontText = defineComponent<FontTextProps>('FontText', (props) => {
76
+ const theme = useTheme();
77
+ const capabilities = useCapabilities();
78
+ const measured = useMeasure();
79
+ const { content, font: chosen, wrap, lineGap, ...rest } = props;
80
+
81
+ const unicode = capabilities.unicode !== 'ascii';
82
+ const picked = chosen ?? DEFAULT_FONT;
83
+ const font = !unicode && picked.fallback ? fontAt(picked.fallback) : picked;
84
+ const width = wrap === 'word' ? measured.width : undefined;
85
+ const drawn = banner(
86
+ content, font, inkGlyphs(theme.glyphs, unicode), width ?? Number.POSITIVE_INFINITY, lineGap,
87
+ );
88
+
89
+ // `wrap: 'none'` *after* the spread, so the lines `banner` decided on are the
90
+ // lines drawn. Every one of them is a row of letters; a text node wrapping
91
+ // one cuts letters in half, and a caller who passed a `TextWrap` through
92
+ // `...rest` would be doing exactly that.
93
+ return h('text', { content: drawn, ...rest, wrap: 'none' });
94
+ });