@timmo001/groundwork-tui 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The header block: the app's name as large ASCII art when the terminal has
3
+ * room, then the header bar. Shared by every view, so the top of the app
4
+ * stays put as views change.
5
+ *
6
+ * @module
7
+ */
8
+ import { ASCIIFontRenderable, TextRenderable } from "@opentui/core";
9
+ import type { BoxRenderable, CliRenderer } from "@opentui/core";
10
+ import { formatHeaderBar } from "./header-bar.ts";
11
+ import type { HeaderStatus } from "./header-bar.ts";
12
+ import type { Theme } from "./theme.ts";
13
+
14
+ /** The narrowest terminal that gets the large block font. */
15
+ const MIN_WIDTH_FOR_BLOCK = 160;
16
+
17
+ /** The shortest terminal that shows the ASCII art at all. */
18
+ const MIN_HEIGHT_FOR_ART = 24;
19
+
20
+ export interface HeaderBlockOptions {
21
+ /** Prefix for the renderables' ids */
22
+ readonly id: string;
23
+ /** The app's name, drawn as ASCII art */
24
+ readonly name: string;
25
+ /** The breadcrumb, such as `["Settings", "Theme"]` */
26
+ readonly titleParts: readonly string[];
27
+ readonly status: HeaderStatus;
28
+ }
29
+
30
+ /**
31
+ * The ASCII art name and the header bar. Both are added straight to `parent`,
32
+ * with no wrapper box, so the ASCII art takes part in its flex layout.
33
+ */
34
+ export class HeaderBlock {
35
+ private readonly renderer: CliRenderer;
36
+ private readonly theme: Theme;
37
+ private readonly art: ASCIIFontRenderable;
38
+ private readonly bar: TextRenderable;
39
+ private titleParts: readonly string[];
40
+ private status: HeaderStatus;
41
+
42
+ constructor(
43
+ renderer: CliRenderer,
44
+ theme: Theme,
45
+ parent: BoxRenderable,
46
+ options: HeaderBlockOptions,
47
+ ) {
48
+ this.renderer = renderer;
49
+ this.theme = theme;
50
+ this.titleParts = options.titleParts;
51
+ this.status = options.status;
52
+
53
+ this.art = new ASCIIFontRenderable(renderer, {
54
+ id: `${options.id}-art`,
55
+ text: options.name,
56
+ font: this.pickFont(),
57
+ color: theme.accent,
58
+ alignSelf: "center",
59
+ });
60
+ this.art.visible = this.hasRoom();
61
+ parent.add(this.art);
62
+
63
+ this.bar = new TextRenderable(renderer, {
64
+ id: `${options.id}-bar`,
65
+ content: "",
66
+ wrapMode: "none",
67
+ truncate: true,
68
+ flexShrink: 0,
69
+ marginTop: 1,
70
+ marginBottom: 1,
71
+ });
72
+ parent.add(this.bar);
73
+ this.render();
74
+
75
+ renderer.on("resize", this.onResize);
76
+ }
77
+
78
+ /** Changes the status, and the breadcrumb when given. */
79
+ update(status: HeaderStatus, titleParts?: readonly string[]): void {
80
+ this.status = status;
81
+
82
+ if (titleParts !== undefined) this.titleParts = titleParts;
83
+
84
+ this.render();
85
+ }
86
+
87
+ /** Changes the breadcrumb. */
88
+ setTitleParts(titleParts: readonly string[]): void {
89
+ this.titleParts = titleParts;
90
+ this.render();
91
+ }
92
+
93
+ /** Stops following terminal resizes. */
94
+ destroy(): void {
95
+ this.renderer.off("resize", this.onResize);
96
+ }
97
+
98
+ private readonly onResize = (): void => {
99
+ this.art.visible = this.hasRoom();
100
+ this.art.font = this.pickFont();
101
+ this.render();
102
+ };
103
+
104
+ private render(): void {
105
+ this.bar.content = formatHeaderBar(
106
+ this.theme,
107
+ this.titleParts,
108
+ this.status,
109
+ this.renderer.width,
110
+ );
111
+ }
112
+
113
+ private hasRoom(): boolean {
114
+ return this.renderer.height >= MIN_HEIGHT_FOR_ART;
115
+ }
116
+
117
+ private pickFont(): "block" | "tiny" {
118
+ return this.renderer.width >= MIN_WIDTH_FOR_BLOCK ? "block" : "tiny";
119
+ }
120
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The help bar along the bottom of each view: key and action pairs, wrapped
3
+ * to the terminal's width.
4
+ *
5
+ * @module
6
+ */
7
+ import { StyledText, fg } from "@opentui/core";
8
+ import type { TextChunk } from "@opentui/core";
9
+ import type { Theme } from "./theme.ts";
10
+
11
+ /** A key-action pair displayed in a help bar */
12
+ export interface HelpEntry {
13
+ /** Key or key combination (e.g. "↑↓", "Enter", "Esc/Backspace") */
14
+ readonly key: string;
15
+ /** Action description (e.g. "navigate", "quit") */
16
+ readonly action: string;
17
+ }
18
+
19
+ /** Global help entries appended to every view's help bar */
20
+ export const GLOBAL_HELP: readonly HelpEntry[] = [
21
+ { key: "Ctrl+c", action: "quit" },
22
+ ];
23
+
24
+ /** Separator between key-action pairs (visible width) */
25
+ const SEPARATOR = " ";
26
+
27
+ /**
28
+ * Format help bar entries into styled text with automatic row wrapping.
29
+ *
30
+ * Keys are rendered in muted text and actions in subtle text so keyboard
31
+ * shortcuts stand out. When the total width exceeds terminal columns,
32
+ * entries wrap onto multiple rows.
33
+ *
34
+ * @param theme - Active color theme
35
+ * @param entries - Key-action pairs to display
36
+ * @param suffix - Optional styled suffix appended after the last row (e.g. model ID badge)
37
+ * @param columns - Width to wrap at, the terminal's by default
38
+ */
39
+ export function formatHelpBar(
40
+ theme: Theme,
41
+ entries: readonly HelpEntry[],
42
+ suffix?: TextChunk,
43
+ columns: number = process.stdout.columns || 80,
44
+ ): StyledText {
45
+ // Wrap into rows that fit within terminal width using plain-text widths
46
+ const rows: HelpEntry[][] = [];
47
+ let currentWidth = 0;
48
+ let currentRow: HelpEntry[] = [];
49
+
50
+ for (const entry of entries) {
51
+ const partWidth = `${entry.key} ${entry.action}`.length;
52
+
53
+ const candidateWidth =
54
+ currentRow.length > 0
55
+ ? currentWidth + SEPARATOR.length + partWidth
56
+ : partWidth;
57
+
58
+ if (currentRow.length > 0 && candidateWidth > columns) {
59
+ rows.push(currentRow);
60
+ currentRow = [entry];
61
+ currentWidth = partWidth;
62
+ } else {
63
+ currentRow.push(entry);
64
+ currentWidth = candidateWidth;
65
+ }
66
+ }
67
+
68
+ if (currentRow.length > 0) rows.push(currentRow);
69
+
70
+ // Build styled chunks with color-coded keys and actions
71
+ const chunks: TextChunk[] = [];
72
+
73
+ for (const [r, row] of rows.entries()) {
74
+ if (r > 0) chunks.push(fg(theme.fgSubtle)("\n"));
75
+
76
+ for (const [j, entry] of row.entries()) {
77
+ if (j > 0) chunks.push(fg(theme.fgSubtle)(SEPARATOR));
78
+
79
+ chunks.push(fg(theme.fgMuted)(entry.key));
80
+ chunks.push(fg(theme.fgSubtle)(` ${entry.action}`));
81
+ }
82
+ }
83
+
84
+ if (suffix) {
85
+ chunks.push(fg(theme.fgSubtle)(" "));
86
+ chunks.push(suffix);
87
+ }
88
+
89
+ return new StyledText(chunks);
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Groundwork's terminal components: OpenTUI renderables and formatters on
3
+ * the terminal tokens, so every Groundwork TUI looks and behaves the same.
4
+ * Docs: https://groundwork.timmo.dev/terminal
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ export { AppHeader, type AppHeaderOptions } from "./app-header.ts";
9
+
10
+ export { formatBreadcrumb } from "./breadcrumb.ts";
11
+
12
+ export { Button, type ButtonOptions, type ButtonVariant } from "./button.ts";
13
+
14
+ export {
15
+ ChoiceDialog,
16
+ type Choice,
17
+ type ChoiceDialogOptions,
18
+ } from "./choice-dialog.ts";
19
+
20
+ export { Collapsible, type CollapsibleOptions } from "./collapsible.ts";
21
+
22
+ export { ConfirmDialog, type ConfirmDialogOptions } from "./confirm-dialog.ts";
23
+
24
+ export { Dialog, type DialogOptions } from "./dialog.ts";
25
+
26
+ export { formatFilterBar } from "./filter-bar.ts";
27
+
28
+ export {
29
+ formatHeaderBar,
30
+ type HeaderStatus,
31
+ type HeaderTone,
32
+ } from "./header-bar.ts";
33
+
34
+ export { HeaderBlock, type HeaderBlockOptions } from "./header-block.ts";
35
+
36
+ export { formatHelpBar, GLOBAL_HELP, type HelpEntry } from "./help-bar.ts";
37
+
38
+ export { Input, type InputOptions } from "./input.ts";
39
+
40
+ export {
41
+ MenuGrid,
42
+ type MenuGridItem,
43
+ type MenuGridOptions,
44
+ type MenuGridSection,
45
+ } from "./menu-grid.ts";
46
+
47
+ export {
48
+ MenuList,
49
+ type MenuListItem,
50
+ type MenuListOptions,
51
+ } from "./menu-list.ts";
52
+
53
+ export { PaneHeader } from "./pane-header.ts";
54
+
55
+ export {
56
+ RadioGroup,
57
+ type RadioChoice,
58
+ type RadioGroupOptions,
59
+ } from "./radio-group.ts";
60
+
61
+ export { ScrollSurface, type ScrollSurfaceOptions } from "./scroll-surface.ts";
62
+
63
+ export {
64
+ SectionHeading,
65
+ type SectionHeadingOptions,
66
+ } from "./section-heading.ts";
67
+
68
+ export {
69
+ StatusList,
70
+ type StatusListItem,
71
+ type StatusListOptions,
72
+ } from "./status-list.ts";
73
+
74
+ export { surfaceBackground } from "./styles.ts";
75
+
76
+ export { DEFAULT_THEME, loadTheme, themeFor, type Theme } from "./theme.ts";
77
+
78
+ export { Tile, type TileOptions } from "./tile.ts";
79
+
80
+ export { Toast, type ToastVariant } from "./toast.ts";
package/src/input.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The text input: OpenTUI's one-line input in the theme's input colors, with
3
+ * the accent cursor and a lighter background while focused.
4
+ *
5
+ * @module
6
+ */
7
+ import { InputRenderable } from "@opentui/core";
8
+ import type { CliRenderer, InputRenderableOptions } from "@opentui/core";
9
+ import type { Theme } from "./theme.ts";
10
+
11
+ export interface InputOptions extends InputRenderableOptions {
12
+ readonly id: string;
13
+ readonly theme: Theme;
14
+ }
15
+
16
+ /** A one-line text input in the theme's colors; full width by default. */
17
+ export class Input extends InputRenderable {
18
+ constructor(renderer: CliRenderer, { theme, ...options }: InputOptions) {
19
+ super(renderer, {
20
+ width: "100%",
21
+ backgroundColor: theme.bgInput,
22
+ focusedBackgroundColor: theme.bgSelected,
23
+ textColor: theme.fg,
24
+ focusedTextColor: theme.fg,
25
+ placeholderColor: theme.fgSubtle,
26
+ cursorColor: theme.accent,
27
+ ...options,
28
+ });
29
+ }
30
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The menu grid: sections of wrapping tiles, each under a section heading,
3
+ * in a scroll box the app owns. The arrow keys move to the nearest tile in
4
+ * that direction, across sections too.
5
+ *
6
+ * @module
7
+ */
8
+ import type { CliRenderer, KeyEvent, ScrollBoxRenderable } from "@opentui/core";
9
+ import { SectionHeading } from "./section-heading.ts";
10
+ import type { Theme } from "./theme.ts";
11
+ import { Tile } from "./tile.ts";
12
+
13
+ export interface MenuGridItem {
14
+ readonly id: string;
15
+ readonly primary: string;
16
+ readonly secondary?: string | readonly string[];
17
+ readonly icon?: string;
18
+ }
19
+
20
+ export interface MenuGridSection {
21
+ readonly id: string;
22
+ /** No heading row when empty */
23
+ readonly title: string;
24
+ readonly icon?: string;
25
+ readonly items: readonly MenuGridItem[];
26
+ }
27
+
28
+ export interface MenuGridOptions {
29
+ /**
30
+ * The view's scroll box, which should wrap its content in rows
31
+ * (`contentOptions: { flexDirection: "row", flexWrap: "wrap" }`)
32
+ */
33
+ readonly scroll: ScrollBoxRenderable;
34
+ /** 28 columns by default */
35
+ readonly tileWidth?: number;
36
+ }
37
+
38
+ interface GridSection {
39
+ readonly items: readonly MenuGridItem[];
40
+ readonly heading: SectionHeading | undefined;
41
+ }
42
+
43
+ interface TilePosition {
44
+ readonly id: string;
45
+ readonly section: number;
46
+ readonly index: number;
47
+ readonly x: number;
48
+ readonly y: number;
49
+ }
50
+
51
+ type Direction = "left" | "right" | "up" | "down";
52
+
53
+ const DIRECTION_KEYS = new Map<string, Direction>([
54
+ ["left", "left"],
55
+ ["right", "right"],
56
+ ["up", "up"],
57
+ ["down", "down"],
58
+ ]);
59
+
60
+ /** Tiles in sections, filling a scroll box the app owns. */
61
+ export class MenuGrid {
62
+ private readonly renderer: CliRenderer;
63
+ private readonly theme: Theme;
64
+ private readonly scroll: ScrollBoxRenderable;
65
+ private readonly tileWidth: number;
66
+ private sections: GridSection[] = [];
67
+ private readonly tiles = new Map<string, Tile>();
68
+ private sectionIndex = 0;
69
+ private tileIndex = 0;
70
+ private focused = false;
71
+
72
+ constructor(renderer: CliRenderer, theme: Theme, options: MenuGridOptions) {
73
+ this.renderer = renderer;
74
+ this.theme = theme;
75
+ this.scroll = options.scroll;
76
+ this.tileWidth = options.tileWidth ?? 28;
77
+
78
+ renderer.on("resize", () => {
79
+ if (this.focused) this.applySelection();
80
+ });
81
+ }
82
+
83
+ /** Whether there are any tiles */
84
+ get hasEntries(): boolean {
85
+ return this.tiles.size > 0;
86
+ }
87
+
88
+ /** Whether a tile is highlighted */
89
+ get isFocused(): boolean {
90
+ return this.focused;
91
+ }
92
+
93
+ /** The highlighted tile's id */
94
+ getSelectedId(): string | undefined {
95
+ return this.sections[this.sectionIndex]?.items[this.tileIndex]?.id;
96
+ }
97
+
98
+ /** Replaces the tiles; sections without items are skipped. */
99
+ setSections(sections: readonly MenuGridSection[]): void {
100
+ this.clearContent();
101
+ this.sectionIndex = 0;
102
+ this.tileIndex = 0;
103
+
104
+ for (const section of sections) {
105
+ if (section.items.length === 0) continue;
106
+
107
+ const heading =
108
+ section.title.length > 0
109
+ ? new SectionHeading(this.renderer, this.theme, {
110
+ id: `${this.scroll.id}-heading-${section.id}`,
111
+ title: section.title,
112
+ icon: section.icon,
113
+ marginTop: this.sections.length === 0 ? 0 : 1,
114
+ })
115
+ : undefined;
116
+
117
+ if (heading) this.scroll.add(heading.box);
118
+
119
+ this.sections.push({ items: section.items, heading });
120
+
121
+ for (const item of section.items) {
122
+ const tile = new Tile(this.renderer, this.theme, {
123
+ id: `${this.scroll.id}-tile-${item.id}`,
124
+ primary: item.primary,
125
+ secondary: item.secondary,
126
+ icon: item.icon,
127
+ width: this.tileWidth,
128
+ });
129
+
130
+ this.tiles.set(item.id, tile);
131
+ this.scroll.add(tile.box);
132
+ }
133
+ }
134
+
135
+ if (this.focused) this.applySelection();
136
+ }
137
+
138
+ /** One section without a heading. */
139
+ setItems(items: readonly MenuGridItem[]): void {
140
+ this.setSections([{ id: "default", title: "", items }]);
141
+ }
142
+
143
+ /** Changes one tile's text in place. */
144
+ updateItem(
145
+ id: string,
146
+ patch: {
147
+ readonly primary?: string;
148
+ readonly secondary?: string | readonly string[];
149
+ },
150
+ ): void {
151
+ const tile = this.tiles.get(id);
152
+
153
+ if (patch.primary !== undefined) tile?.setPrimary(patch.primary);
154
+
155
+ if (patch.secondary !== undefined) tile?.setSecondary(patch.secondary);
156
+ }
157
+
158
+ /** Removes every tile and heading. */
159
+ clear(): void {
160
+ this.clearContent();
161
+ this.sectionIndex = 0;
162
+ this.tileIndex = 0;
163
+ this.focused = false;
164
+ }
165
+
166
+ /** Highlights the selected tile. */
167
+ focus(): void {
168
+ if (this.tiles.size === 0) return;
169
+
170
+ this.focused = true;
171
+ this.applySelection();
172
+ }
173
+
174
+ /** Clears the highlight, keeping the selection. */
175
+ blur(): void {
176
+ this.focused = false;
177
+
178
+ for (const tile of this.tiles.values()) tile.setSelected(false);
179
+ }
180
+
181
+ /** Selects the first tile and scrolls to the top. */
182
+ resetSelection(): void {
183
+ this.sectionIndex = 0;
184
+ this.tileIndex = 0;
185
+ this.scroll.scrollTop = 0;
186
+ this.applySelection();
187
+ }
188
+
189
+ /** Handles the arrow keys; returns whether it used the key. */
190
+ handleKeyPress(key: KeyEvent): boolean {
191
+ const direction = DIRECTION_KEYS.get(key.name);
192
+
193
+ if (this.tiles.size === 0 || direction === undefined) return false;
194
+
195
+ if (!this.focused) this.focus();
196
+
197
+ this.move(direction);
198
+
199
+ return true;
200
+ }
201
+
202
+ private clearContent(): void {
203
+ for (const tile of this.tiles.values()) this.scroll.remove(tile.box);
204
+
205
+ for (const { heading } of this.sections)
206
+ if (heading) this.scroll.remove(heading.box);
207
+
208
+ this.tiles.clear();
209
+ this.sections = [];
210
+ }
211
+
212
+ /** Moves to the nearest tile in that direction, preferring the same line. */
213
+ private move(direction: Direction): void {
214
+ const positions = this.positions();
215
+ const current = positions.find(({ id }) => id === this.getSelectedId());
216
+
217
+ if (current === undefined) return;
218
+
219
+ let best: TilePosition | undefined;
220
+ let bestScore = Number.POSITIVE_INFINITY;
221
+
222
+ for (const candidate of positions) {
223
+ const dx = candidate.x - current.x;
224
+ const dy = candidate.y - current.y;
225
+ const horizontal = direction === "left" || direction === "right";
226
+ const ahead = horizontal ? dx : dy;
227
+ const across = horizontal ? dy : dx;
228
+ const forward = direction === "right" || direction === "down";
229
+ const distance = forward ? ahead : -ahead;
230
+
231
+ if (distance <= 0) continue;
232
+
233
+ const score = Math.abs(across) * 1000 + distance;
234
+
235
+ if (score >= bestScore) continue;
236
+
237
+ bestScore = score;
238
+ best = candidate;
239
+ }
240
+
241
+ if (best === undefined) return;
242
+
243
+ this.sectionIndex = best.section;
244
+ this.tileIndex = best.index;
245
+ this.applySelection();
246
+ }
247
+
248
+ private positions(): TilePosition[] {
249
+ return this.sections.flatMap(({ items }, section) =>
250
+ items.flatMap(({ id }, index) => {
251
+ const box = this.tiles.get(id)?.box;
252
+
253
+ return box === undefined
254
+ ? []
255
+ : [
256
+ {
257
+ id,
258
+ section,
259
+ index,
260
+ x: box.x + box.width / 2,
261
+ y: box.y + box.height / 2,
262
+ },
263
+ ];
264
+ }),
265
+ );
266
+ }
267
+
268
+ private applySelection(): void {
269
+ for (const tile of this.tiles.values()) tile.setSelected(false);
270
+
271
+ const id = this.getSelectedId();
272
+ const tile = id === undefined ? undefined : this.tiles.get(id);
273
+
274
+ if (!this.focused || tile === undefined) return;
275
+
276
+ tile.setSelected(true);
277
+ this.scroll.scrollChildIntoView(tile.box.id);
278
+ }
279
+ }