@timmo001/groundwork-tui 0.9.31 → 0.9.32

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 CHANGED
@@ -8,8 +8,10 @@ look and keys.
8
8
  import { AppHeader, formatHelpBar, loadTheme } from "@timmo001/groundwork-tui";
9
9
  ```
10
10
 
11
- - `loadTheme` reads `GW_THEME` and `GW_MODE` (Base and dark by default), and
12
- `themeFor` picks a theme and mode directly.
11
+ - `loadTheme` reads `GW_THEME` and `GW_MODE`. Without `GW_THEME` it's the
12
+ desktop's Omarchy theme where there is one, otherwise Base and dark.
13
+ `themeFor` picks a theme and mode directly, and `omarchyThemeFor` maps an
14
+ Omarchy `colors.toml`.
13
15
  - `AppHeader` and `PaneHeader` are one-row titles. `HeaderBlock` is the
14
16
  app's name as ASCII art over `formatHeaderBar`, a breadcrumb with a status
15
17
  dot and details that drop off when the terminal is narrow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/groundwork-tui",
3
- "version": "0.9.31",
3
+ "version": "0.9.32",
4
4
  "description": "Groundwork's terminal components: OpenTUI renderables on the terminal tokens",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@opentui/core": "0.8.1",
33
- "@timmo001/groundwork-tokens": "0.9.31",
33
+ "@timmo001/groundwork-tokens": "0.9.32",
34
34
  "effect": "4.1.1",
35
35
  "fuse.js": "7.5.0"
36
36
  },
package/src/index.ts CHANGED
@@ -73,7 +73,17 @@ export {
73
73
 
74
74
  export { surfaceBackground } from "./styles.ts";
75
75
 
76
- export { DEFAULT_THEME, loadTheme, themeFor, type Theme } from "./theme.ts";
76
+ export {
77
+ DEFAULT_THEME,
78
+ loadTheme,
79
+ omarchyThemeFor,
80
+ resolveTheme,
81
+ themeFor,
82
+ themeOptions,
83
+ type Theme,
84
+ type ThemeOption,
85
+ type ThemePick,
86
+ } from "./theme.ts";
77
87
 
78
88
  export { Tile, type TileOptions } from "./tile.ts";
79
89
 
package/src/menu-grid.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The menu grid: sections of wrapping tiles, each under a section heading,
3
3
  * in a scroll box the app owns. The arrow keys move to the nearest tile in
4
- * that direction, across sections too.
4
+ * that direction, across sections too, and a click picks a tile.
5
5
  *
6
6
  * @module
7
7
  */
@@ -33,6 +33,8 @@ export interface MenuGridOptions {
33
33
  readonly scroll: ScrollBoxRenderable;
34
34
  /** 28 columns by default */
35
35
  readonly tileWidth?: number;
36
+ /** Called with a tile's id when it's clicked, after it's highlighted */
37
+ readonly onActivate?: (id: string) => void;
36
38
  }
37
39
 
38
40
  interface GridSection {
@@ -63,6 +65,7 @@ export class MenuGrid {
63
65
  private readonly theme: Theme;
64
66
  private readonly scroll: ScrollBoxRenderable;
65
67
  private readonly tileWidth: number;
68
+ private readonly onActivate: ((id: string) => void) | undefined;
66
69
  private sections: GridSection[] = [];
67
70
  private readonly tiles = new Map<string, Tile>();
68
71
  private sectionIndex = 0;
@@ -74,6 +77,7 @@ export class MenuGrid {
74
77
  this.theme = theme;
75
78
  this.scroll = options.scroll;
76
79
  this.tileWidth = options.tileWidth ?? 28;
80
+ this.onActivate = options.onActivate;
77
81
 
78
82
  renderer.on("resize", () => {
79
83
  if (this.focused) this.applySelection();
@@ -116,9 +120,11 @@ export class MenuGrid {
116
120
 
117
121
  if (heading) this.scroll.add(heading.box);
118
122
 
123
+ const sectionIndex = this.sections.length;
124
+
119
125
  this.sections.push({ items: section.items, heading });
120
126
 
121
- for (const item of section.items) {
127
+ for (const [tileIndex, item] of section.items.entries()) {
122
128
  const tile = new Tile(this.renderer, this.theme, {
123
129
  id: `${this.scroll.id}-tile-${item.id}`,
124
130
  primary: item.primary,
@@ -127,6 +133,16 @@ export class MenuGrid {
127
133
  width: this.tileWidth,
128
134
  });
129
135
 
136
+ // A click highlights the tile and runs it, as Enter does in the app
137
+ tile.box.onMouseDown = (event) => {
138
+ if (event.button !== 0) return;
139
+
140
+ this.sectionIndex = sectionIndex;
141
+ this.tileIndex = tileIndex;
142
+ this.focus();
143
+ this.onActivate?.(item.id);
144
+ };
145
+
130
146
  this.tiles.set(item.id, tile);
131
147
  this.scroll.add(tile.box);
132
148
  }
package/src/menu-list.ts CHANGED
@@ -60,7 +60,7 @@ export interface MenuListOptions<Item extends MenuListItem> {
60
60
  readonly items: readonly Item[];
61
61
  /** Active color theme */
62
62
  readonly theme: Theme;
63
- /** Called when the user presses Enter on an item */
63
+ /** Called when the user presses Enter on an item, or clicks it */
64
64
  readonly onSelect: (item: Item) => void;
65
65
  /** Called when the highlighted item changes */
66
66
  readonly onSelectionChanged?: (item: Item) => void;
@@ -99,8 +99,8 @@ const isPrintable = (key: KeyEvent): boolean =>
99
99
 
100
100
  /**
101
101
  * A scrolling menu of two-line rows: the icon and title, then the
102
- * description. Up and Down move, Enter selects, Escape clears the filter or
103
- * goes back, and Backspace removes a filter character or goes back.
102
+ * description. Up and Down move, Enter or a click selects, Escape clears the
103
+ * filter or goes back, and Backspace removes a filter character or goes back.
104
104
  */
105
105
  export class MenuList<
106
106
  Item extends MenuListItem = MenuListItem,
@@ -346,11 +346,7 @@ export class MenuList<
346
346
  }
347
347
 
348
348
  if (key.name === "return") {
349
- const entry = this.rows[this.selectedIndex]?.entry;
350
-
351
- if (entry?.kind === "item") this.options.onSelect(entry.item);
352
- else if (entry?.kind === "next") this.turnPage(1);
353
- else if (entry?.kind === "previous") this.turnPage(-1);
349
+ this.activate(this.selectedIndex);
354
350
 
355
351
  return true;
356
352
  }
@@ -366,6 +362,15 @@ export class MenuList<
366
362
  return super.handleKeyPress(key);
367
363
  }
368
364
 
365
+ /** Runs a row: selects its item, or turns the page. */
366
+ private activate(index: number): void {
367
+ const entry = this.rows[index]?.entry;
368
+
369
+ if (entry?.kind === "item") this.options.onSelect(entry.item);
370
+ else if (entry?.kind === "next") this.turnPage(1);
371
+ else if (entry?.kind === "previous") this.turnPage(-1);
372
+ }
373
+
369
374
  /** `/` opens the filter, which then takes every key until Escape or Enter. */
370
375
  private handleSlashFilter(key: KeyEvent): boolean {
371
376
  if (!this.slashFilterOpen) {
@@ -646,7 +651,29 @@ export class MenuList<
646
651
  container.add(iconCol);
647
652
  container.add(textCol);
648
653
 
649
- return { container, iconText, titleText, descText, entry };
654
+ const row: MenuRow<Item> = {
655
+ container,
656
+ iconText,
657
+ titleText,
658
+ descText,
659
+ entry,
660
+ };
661
+
662
+ // A click selects the row and runs it, as Enter does
663
+ container.onMouseDown = (event) => {
664
+ const rowIndex = this.rows.indexOf(row);
665
+
666
+ if (event.button !== 0 || rowIndex < 0 || row.entry.kind === "group")
667
+ return;
668
+
669
+ this.focus();
670
+
671
+ if (rowIndex !== this.selectedIndex) this.applySelection(rowIndex);
672
+
673
+ this.activate(rowIndex);
674
+ };
675
+
676
+ return row;
650
677
  }
651
678
 
652
679
  private paintRow(row: MenuRow<Item>, selected: boolean): void {
package/src/theme.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * Terminal colors from Groundwork's tokens: the roles of one theme and mode,
3
3
  * placed into the slots the terminal components use, and picked with
4
- * `GW_THEME` and `GW_MODE` (Base and dark by default).
4
+ * `GW_THEME` and `GW_MODE`. With no `GW_THEME`, it's the desktop's Omarchy
5
+ * theme where there is one, otherwise Base and dark.
5
6
  *
6
7
  * @module
7
8
  */
9
+ import { readOmarchyTheme } from "@timmo001/groundwork-tokens/omarchy-current";
8
10
  import { opentuiThemes } from "@timmo001/groundwork-tokens/opentui";
9
- import { Config, Effect } from "effect";
11
+ import { coreThemeChoices } from "@timmo001/groundwork-tokens/page";
12
+ import {
13
+ hexModes,
14
+ omarchyTheme,
15
+ runtimeTheme,
16
+ } from "@timmo001/groundwork-tokens/runtime";
17
+ import { Config, Effect, Option, Result } from "effect";
10
18
 
11
19
  /** Semantic color slots for the TUI */
12
20
  export interface Theme {
@@ -44,26 +52,33 @@ export interface Theme {
44
52
 
45
53
  type ThemeId = keyof typeof opentuiThemes;
46
54
 
47
- type Roles = (typeof opentuiThemes)["base"]["dark"];
48
-
49
- /** Places a theme's terminal roles into the TUI's slots. */
50
- const fromRoles = (roles: Roles): Theme => ({
51
- bg: roles["surface-lower"],
52
- bgElevated: roles["surface-default"],
53
- bgSelected: roles["fill-neutral-selected"],
54
- bgInput: roles["surface-raised"],
55
- accent: roles.accent,
56
- accentFg: roles["on-accent"],
57
- surface: roles["border-normal"],
58
- fg: roles["text-primary"],
59
- fgMuted: roles["text-secondary"],
60
- fgSubtle: roles["text-disabled"],
61
- fgGhost: roles["border-loud"],
62
- green: roles.success,
63
- red: roles.danger,
64
- yellow: roles.warning,
65
- transparent: false,
66
- });
55
+ type RoleName = keyof (typeof opentuiThemes)["base"]["dark"];
56
+
57
+ /**
58
+ * Places a theme's terminal roles into the TUI's slots. A theme solved at
59
+ * runtime has the same roles; Base's dark fills any it lacks.
60
+ */
61
+ const fromRoles = (roles: Partial<Record<RoleName, string>>): Theme => {
62
+ const role = (name: RoleName) => roles[name] ?? opentuiThemes.base.dark[name];
63
+
64
+ return {
65
+ bg: role("surface-lower"),
66
+ bgElevated: role("surface-default"),
67
+ bgSelected: role("fill-neutral-selected"),
68
+ bgInput: role("surface-raised"),
69
+ accent: role("accent"),
70
+ accentFg: role("on-accent"),
71
+ surface: role("border-normal"),
72
+ fg: role("text-primary"),
73
+ fgMuted: role("text-secondary"),
74
+ fgSubtle: role("text-disabled"),
75
+ fgGhost: role("border-loud"),
76
+ green: role("success"),
77
+ red: role("danger"),
78
+ yellow: role("warning"),
79
+ transparent: false,
80
+ };
81
+ };
67
82
 
68
83
  const isTheme = (id: string): id is ThemeId => id in opentuiThemes;
69
84
 
@@ -85,16 +100,105 @@ export const themeFor = (id: string, mode: string): Theme => {
85
100
  };
86
101
 
87
102
  /**
88
- * Loads the theme from `GW_THEME` (a core theme's id) and `GW_MODE` (`light`,
89
- * `dark` or `darker`), falling back to Base and dark.
103
+ * An Omarchy `colors.toml`'s slots in `mode`, or in the theme's own mode when
104
+ * it doesn't have that one. Undefined when the file doesn't map.
105
+ */
106
+ export const omarchyThemeFor = (
107
+ colors: string,
108
+ mode?: string,
109
+ ): Theme | undefined => {
110
+ const parsed = omarchyTheme(colors);
111
+
112
+ if (Result.isFailure(parsed)) return undefined;
113
+
114
+ const modes = hexModes(runtimeTheme(parsed.success).theme);
115
+
116
+ const roles =
117
+ (mode === undefined ? undefined : modes[mode]) ??
118
+ modes[parsed.success.mode];
119
+
120
+ return roles === undefined ? undefined : fromRoles(roles);
121
+ };
122
+
123
+ /** A theme a TUI can offer in a picker, with the modes it has. */
124
+ export interface ThemeOption {
125
+ readonly id: string;
126
+ readonly name: string;
127
+ readonly modes: readonly string[];
128
+ }
129
+
130
+ /**
131
+ * The themes to offer in a picker: the desktop's Omarchy theme first where
132
+ * there is one, then the core themes.
133
+ */
134
+ export const themeOptions: Effect.Effect<readonly ThemeOption[]> = Effect.gen(
135
+ function* () {
136
+ const core = coreThemeChoices.map(({ id, name }) => ({
137
+ id,
138
+ name,
139
+ modes: Object.keys(isTheme(id) ? opentuiThemes[id] : {}),
140
+ }));
141
+
142
+ const current = yield* Effect.promise(readOmarchyTheme);
143
+ const parsed = current === null ? undefined : omarchyTheme(current.colors);
144
+
145
+ if (current === null || parsed === undefined || Result.isFailure(parsed))
146
+ return core;
147
+
148
+ const omarchy = {
149
+ id: "omarchy",
150
+ name: `Omarchy (${current.name})`,
151
+ modes: Object.keys(hexModes(runtimeTheme(parsed.success).theme)),
152
+ };
153
+
154
+ return [omarchy, ...core];
155
+ },
156
+ );
157
+
158
+ /** A theme and mode to show; either can be left to the defaults. */
159
+ export interface ThemePick {
160
+ /** A core theme's id, or `omarchy` for the desktop's Omarchy theme */
161
+ readonly theme?: string | undefined;
162
+ /** `light`, `dark` or `darker` */
163
+ readonly mode?: string | undefined;
164
+ }
165
+
166
+ /**
167
+ * A pick's slots. Without a theme it's the desktop's Omarchy theme where
168
+ * there is one, otherwise Base; without a mode it's the Omarchy theme's own,
169
+ * otherwise dark.
170
+ */
171
+ export const resolveTheme = (pick: ThemePick): Effect.Effect<Theme> =>
172
+ Effect.gen(function* () {
173
+ if (pick.theme === undefined || pick.theme === "omarchy") {
174
+ const current = yield* Effect.promise(readOmarchyTheme);
175
+
176
+ const theme =
177
+ current === null
178
+ ? undefined
179
+ : omarchyThemeFor(current.colors, pick.mode);
180
+
181
+ if (theme !== undefined) return theme;
182
+ }
183
+
184
+ return themeFor(pick.theme ?? "base", pick.mode ?? "dark");
185
+ });
186
+
187
+ /**
188
+ * Loads the theme from `GW_THEME` and `GW_MODE` (`light`, `dark` or
189
+ * `darker`), as `resolveTheme` picks it: `GW_THEME` is a core theme's id, or
190
+ * `omarchy` for the desktop's Omarchy theme, which is also the default where
191
+ * Omarchy is installed.
90
192
  */
91
193
  export const loadTheme: Effect.Effect<Theme> = Effect.gen(function* () {
92
- const id = yield* Config.String("GW_THEME").pipe(Config.withDefault("base"));
194
+ const theme = yield* Config.option(Config.String("GW_THEME"));
93
195
 
94
- const mode = yield* Config.Literals(
95
- ["light", "dark", "darker"],
96
- "GW_MODE",
97
- ).pipe(Config.withDefault("dark"));
196
+ const mode = yield* Config.option(
197
+ Config.Literals(["light", "dark", "darker"], "GW_MODE"),
198
+ );
98
199
 
99
- return themeFor(id, mode);
200
+ return yield* resolveTheme({
201
+ theme: Option.getOrUndefined(theme),
202
+ mode: Option.getOrUndefined(mode),
203
+ });
100
204
  }).pipe(Effect.orElseSucceed(() => DEFAULT_THEME));