@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 +4 -2
- package/package.json +2 -2
- package/src/index.ts +11 -1
- package/src/menu-grid.ts +18 -2
- package/src/menu-list.ts +36 -9
- package/src/theme.ts +134 -30
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
|
|
12
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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 {
|
|
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
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
|
194
|
+
const theme = yield* Config.option(Config.String("GW_THEME"));
|
|
93
195
|
|
|
94
|
-
const mode = yield* Config.
|
|
95
|
-
["light", "dark", "darker"],
|
|
96
|
-
|
|
97
|
-
).pipe(Config.withDefault("dark"));
|
|
196
|
+
const mode = yield* Config.option(
|
|
197
|
+
Config.Literals(["light", "dark", "darker"], "GW_MODE"),
|
|
198
|
+
);
|
|
98
199
|
|
|
99
|
-
return
|
|
200
|
+
return yield* resolveTheme({
|
|
201
|
+
theme: Option.getOrUndefined(theme),
|
|
202
|
+
mode: Option.getOrUndefined(mode),
|
|
203
|
+
});
|
|
100
204
|
}).pipe(Effect.orElseSucceed(() => DEFAULT_THEME));
|