@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.
- package/LICENSE +201 -0
- package/README.md +29 -2
- package/package.json +44 -4
- package/src/app-header.ts +40 -0
- package/src/breadcrumb.ts +39 -0
- package/src/button.ts +109 -0
- package/src/choice-dialog.ts +109 -0
- package/src/collapsible.ts +119 -0
- package/src/confirm-dialog.ts +121 -0
- package/src/dialog.ts +251 -0
- package/src/filter-bar.ts +15 -0
- package/src/header-bar.ts +93 -0
- package/src/header-block.ts +120 -0
- package/src/help-bar.ts +90 -0
- package/src/index.ts +80 -0
- package/src/input.ts +30 -0
- package/src/menu-grid.ts +279 -0
- package/src/menu-list.ts +684 -0
- package/src/pane-header.ts +34 -0
- package/src/radio-group.ts +146 -0
- package/src/scroll-surface.ts +146 -0
- package/src/section-heading.ts +83 -0
- package/src/status-list.ts +372 -0
- package/src/styles.ts +10 -0
- package/src/theme.ts +100 -0
- package/src/tile.ts +126 -0
- package/src/toast.ts +102 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status list: two-row items colored by their state, with optional
|
|
3
|
+
* section headings, that scroll by whole rows and keep a row's section in
|
|
4
|
+
* view when they can.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { BoxRenderable, TextRenderable, bold, fg, t } from "@opentui/core";
|
|
9
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
10
|
+
import { ScrollSurface } from "./scroll-surface.ts";
|
|
11
|
+
import { surfaceBackground } from "./styles.ts";
|
|
12
|
+
import type { Theme } from "./theme.ts";
|
|
13
|
+
|
|
14
|
+
export interface StatusListItem<T> {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly description: string;
|
|
18
|
+
/** The title's color, usually a state slot such as `theme.green`. */
|
|
19
|
+
readonly color: string;
|
|
20
|
+
/** Starts a new section heading when it differs from the row above. */
|
|
21
|
+
readonly section?: string;
|
|
22
|
+
readonly value: T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StatusListOptions<T> {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly theme: Theme;
|
|
28
|
+
readonly items?: readonly StatusListItem<T>[];
|
|
29
|
+
/** Called on Enter or a click. */
|
|
30
|
+
readonly onSelect: (item: StatusListItem<T>) => void;
|
|
31
|
+
/** Called when the highlighted row changes. */
|
|
32
|
+
readonly onSelectionChanged?: (item: StatusListItem<T>) => void;
|
|
33
|
+
/** Whether Enter selects, true by default. */
|
|
34
|
+
readonly selectOnEnter?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface StatusListRow<T> {
|
|
38
|
+
readonly container: BoxRenderable;
|
|
39
|
+
readonly marker: TextRenderable;
|
|
40
|
+
readonly title: TextRenderable;
|
|
41
|
+
readonly description: TextRenderable;
|
|
42
|
+
readonly item: StatusListItem<T>;
|
|
43
|
+
readonly sectionHeader: BoxRenderable | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A two-row list of items with a state color and section headings. */
|
|
47
|
+
export class StatusList<T> extends ScrollSurface {
|
|
48
|
+
private readonly listRenderer: CliRenderer;
|
|
49
|
+
private readonly listTheme: Theme;
|
|
50
|
+
private readonly onSelectItem: (item: StatusListItem<T>) => void;
|
|
51
|
+
private readonly onItemSelectionChanged:
|
|
52
|
+
((item: StatusListItem<T>) => void) | undefined;
|
|
53
|
+
private readonly selectOnEnter: boolean;
|
|
54
|
+
private rows: StatusListRow<T>[] = [];
|
|
55
|
+
private items: readonly StatusListItem<T>[] = [];
|
|
56
|
+
private selectedIndex = 0;
|
|
57
|
+
private active = false;
|
|
58
|
+
|
|
59
|
+
constructor(renderer: CliRenderer, options: StatusListOptions<T>) {
|
|
60
|
+
super(renderer, {
|
|
61
|
+
id: options.id,
|
|
62
|
+
theme: options.theme,
|
|
63
|
+
flexGrow: 1,
|
|
64
|
+
flexShrink: 1,
|
|
65
|
+
minHeight: 0,
|
|
66
|
+
scrollY: true,
|
|
67
|
+
scrollX: false,
|
|
68
|
+
focusable: true,
|
|
69
|
+
backgroundColor: surfaceBackground(options.theme),
|
|
70
|
+
contentOptions: { flexDirection: "column", width: "100%" },
|
|
71
|
+
});
|
|
72
|
+
this.listRenderer = renderer;
|
|
73
|
+
this.listTheme = options.theme;
|
|
74
|
+
this.onSelectItem = options.onSelect;
|
|
75
|
+
this.onItemSelectionChanged = options.onSelectionChanged;
|
|
76
|
+
this.selectOnEnter = options.selectOnEnter ?? true;
|
|
77
|
+
this.setItems(options.items ?? []);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Replaces the rows, keeping `preferredId` or the current row selected. */
|
|
81
|
+
setItems(
|
|
82
|
+
items: readonly StatusListItem<T>[],
|
|
83
|
+
preferredId?: string | null,
|
|
84
|
+
): void {
|
|
85
|
+
const selectedId = preferredId ?? this.getSelectedItem()?.id;
|
|
86
|
+
|
|
87
|
+
for (const row of this.scrollBox.getChildren()) this.scrollBox.remove(row);
|
|
88
|
+
|
|
89
|
+
this.items = items;
|
|
90
|
+
|
|
91
|
+
this.selectedIndex = Math.max(
|
|
92
|
+
0,
|
|
93
|
+
selectedId ? items.findIndex((item) => item.id === selectedId) : 0,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
this.rows = [];
|
|
97
|
+
let section: string | undefined;
|
|
98
|
+
let sectionHeader: BoxRenderable | undefined;
|
|
99
|
+
|
|
100
|
+
for (const [index, item] of items.entries()) {
|
|
101
|
+
if (item.section && item.section !== section) {
|
|
102
|
+
section = item.section;
|
|
103
|
+
sectionHeader = this.createSection(item.section, index);
|
|
104
|
+
this.addContent(sectionHeader);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const row = this.createRow(item, index, sectionHeader);
|
|
108
|
+
this.rows.push(row);
|
|
109
|
+
this.addContent(row.container);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.refresh();
|
|
113
|
+
this.ensureSelectionVisible();
|
|
114
|
+
this.emitSelection();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getSelectedItem(): StatusListItem<T> | undefined {
|
|
118
|
+
return this.items[this.selectedIndex];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Marks the list as the focused pane, and focuses it unless told not to. */
|
|
122
|
+
setActive(active: boolean, options?: { readonly focus?: boolean }): void {
|
|
123
|
+
this.active = active;
|
|
124
|
+
|
|
125
|
+
if (active && (options?.focus ?? true)) this.focus();
|
|
126
|
+
else this.blur();
|
|
127
|
+
|
|
128
|
+
this.refresh();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
selectNext(): void {
|
|
132
|
+
this.moveSelection(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
selectPrevious(): void {
|
|
136
|
+
this.moveSelection(-1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Scrolls the selected row back into view, such as after a resize. */
|
|
140
|
+
realign(): void {
|
|
141
|
+
this.ensureSelectionVisible();
|
|
142
|
+
this.syncMarker();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override focus(): void {
|
|
146
|
+
this.listRenderer.focusRenderable(this);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override blur(): void {
|
|
150
|
+
this.listRenderer.blurRenderable(this);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
override handleKeyPress(key: KeyEvent): boolean {
|
|
154
|
+
if (key.name === "up" || key.name === "down") {
|
|
155
|
+
this.moveSelection(key.name === "up" ? -1 : 1);
|
|
156
|
+
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (key.name === "pageup" || key.name === "pagedown") {
|
|
161
|
+
const page = this.completeItemsPerPage();
|
|
162
|
+
this.moveSelection(key.name === "pageup" ? -page : page, false);
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (key.name === "return" && this.selectOnEnter) {
|
|
168
|
+
const item = this.getSelectedItem();
|
|
169
|
+
|
|
170
|
+
if (item) this.onSelectItem(item);
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return this.scrollBox.handleKeyPress(key);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private moveSelection(delta: number, wrap = true): void {
|
|
179
|
+
if (!this.items.length) return;
|
|
180
|
+
|
|
181
|
+
const next = wrap
|
|
182
|
+
? (this.selectedIndex + delta + this.items.length) % this.items.length
|
|
183
|
+
: Math.max(
|
|
184
|
+
0,
|
|
185
|
+
Math.min(this.items.length - 1, this.selectedIndex + delta),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (next === this.selectedIndex) return;
|
|
189
|
+
|
|
190
|
+
this.selectedIndex = next;
|
|
191
|
+
this.refresh();
|
|
192
|
+
this.ensureSelectionVisible();
|
|
193
|
+
this.emitSelection();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private activate(index: number): void {
|
|
197
|
+
if (index !== this.selectedIndex) {
|
|
198
|
+
this.selectedIndex = index;
|
|
199
|
+
this.refresh();
|
|
200
|
+
this.ensureSelectionVisible();
|
|
201
|
+
this.emitSelection();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const item = this.items[index];
|
|
205
|
+
|
|
206
|
+
if (item) this.onSelectItem(item);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private createSection(label: string, index: number): BoxRenderable {
|
|
210
|
+
const header = new BoxRenderable(this.listRenderer, {
|
|
211
|
+
id: `${this.id}-section-${index}`,
|
|
212
|
+
width: "100%",
|
|
213
|
+
height: 1,
|
|
214
|
+
flexShrink: 0,
|
|
215
|
+
paddingLeft: 1,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
header.add(
|
|
219
|
+
new TextRenderable(this.listRenderer, {
|
|
220
|
+
content: t`${bold(fg(this.listTheme.fgSubtle)(label))}`,
|
|
221
|
+
height: 1,
|
|
222
|
+
wrapMode: "none",
|
|
223
|
+
truncate: true,
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return header;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private createRow(
|
|
231
|
+
item: StatusListItem<T>,
|
|
232
|
+
index: number,
|
|
233
|
+
sectionHeader: BoxRenderable | undefined,
|
|
234
|
+
): StatusListRow<T> {
|
|
235
|
+
const container = new BoxRenderable(this.listRenderer, {
|
|
236
|
+
id: `${this.id}-row-${index}`,
|
|
237
|
+
flexDirection: "row",
|
|
238
|
+
width: "100%",
|
|
239
|
+
height: 2,
|
|
240
|
+
flexShrink: 0,
|
|
241
|
+
overflow: "hidden",
|
|
242
|
+
onMouseDown: (event) => {
|
|
243
|
+
if (event.button !== 0) return;
|
|
244
|
+
|
|
245
|
+
event.stopPropagation();
|
|
246
|
+
this.activate(index);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const marker = new TextRenderable(this.listRenderer, {
|
|
251
|
+
width: 2,
|
|
252
|
+
height: 2,
|
|
253
|
+
flexShrink: 0,
|
|
254
|
+
content: "",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const content = new BoxRenderable(this.listRenderer, {
|
|
258
|
+
flexDirection: "column",
|
|
259
|
+
flexGrow: 1,
|
|
260
|
+
minWidth: 0,
|
|
261
|
+
height: 2,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const line = {
|
|
265
|
+
height: 1,
|
|
266
|
+
flexShrink: 0,
|
|
267
|
+
wrapMode: "none",
|
|
268
|
+
truncate: true,
|
|
269
|
+
overflow: "hidden",
|
|
270
|
+
content: "",
|
|
271
|
+
} as const;
|
|
272
|
+
|
|
273
|
+
const title = new TextRenderable(this.listRenderer, line);
|
|
274
|
+
const description = new TextRenderable(this.listRenderer, line);
|
|
275
|
+
|
|
276
|
+
content.add(title);
|
|
277
|
+
content.add(description);
|
|
278
|
+
container.add(marker);
|
|
279
|
+
container.add(content);
|
|
280
|
+
|
|
281
|
+
return { container, marker, title, description, item, sectionHeader };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private refresh(): void {
|
|
285
|
+
const theme = this.listTheme;
|
|
286
|
+
|
|
287
|
+
for (const [index, row] of this.rows.entries()) {
|
|
288
|
+
const selected = index === this.selectedIndex;
|
|
289
|
+
const { color, title, description } = row.item;
|
|
290
|
+
|
|
291
|
+
row.container.backgroundColor =
|
|
292
|
+
selected && this.active ? theme.bgSelected : surfaceBackground(theme);
|
|
293
|
+
|
|
294
|
+
row.marker.content = t`${fg(selected ? color : theme.fgGhost)(selected ? "▌\n▌" : " \n ")}`;
|
|
295
|
+
row.title.content = t`${selected ? bold(fg(color)(title)) : fg(color)(title)}`;
|
|
296
|
+
row.description.content = t`${fg(theme.fgMuted)(description)}`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private ensureSelectionVisible(): void {
|
|
301
|
+
const row = this.rows[this.selectedIndex];
|
|
302
|
+
|
|
303
|
+
if (!row) return;
|
|
304
|
+
|
|
305
|
+
const viewport = this.scrollBox.viewport.height;
|
|
306
|
+
const rowStart = this.childStart(row.container);
|
|
307
|
+
|
|
308
|
+
const contextStart = row.sectionHeader
|
|
309
|
+
? this.childStart(row.sectionHeader)
|
|
310
|
+
: rowStart;
|
|
311
|
+
|
|
312
|
+
const rowEnd = rowStart + row.container.height;
|
|
313
|
+
|
|
314
|
+
const targetStart =
|
|
315
|
+
rowEnd - contextStart <= viewport ? contextStart : rowStart;
|
|
316
|
+
|
|
317
|
+
const current = this.scrollBox.scrollTop;
|
|
318
|
+
let target = current;
|
|
319
|
+
|
|
320
|
+
if (targetStart < current) target = targetStart;
|
|
321
|
+
else if (rowEnd > current + viewport) target = rowEnd - viewport;
|
|
322
|
+
|
|
323
|
+
this.scrollBox.scrollTop = this.completeChildBoundary(target);
|
|
324
|
+
this.syncMarker();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private completeItemsPerPage(): number {
|
|
328
|
+
const viewport = this.scrollBox.viewport.height;
|
|
329
|
+
|
|
330
|
+
if (viewport <= 0) return 1;
|
|
331
|
+
|
|
332
|
+
return Math.max(1, Math.floor(viewport / 2));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Rounds an offset down to the start of a whole child. */
|
|
336
|
+
private completeChildBoundary(offset: number): number {
|
|
337
|
+
const extent = Math.max(
|
|
338
|
+
0,
|
|
339
|
+
this.scrollBox.scrollHeight - this.scrollBox.viewport.height,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const bounded = Math.max(0, Math.min(offset, extent));
|
|
343
|
+
const starts: number[] = [];
|
|
344
|
+
let start = 0;
|
|
345
|
+
|
|
346
|
+
for (const child of this.scrollBox.getChildren()) {
|
|
347
|
+
if (start <= bounded) starts.push(start);
|
|
348
|
+
|
|
349
|
+
start += child.height;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return Math.max(0, ...starts);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private childStart(target: BoxRenderable): number {
|
|
356
|
+
let start = 0;
|
|
357
|
+
|
|
358
|
+
for (const child of this.scrollBox.getChildren()) {
|
|
359
|
+
if (child === target) return start;
|
|
360
|
+
|
|
361
|
+
start += child.height;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return start;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private emitSelection(): void {
|
|
368
|
+
const item = this.getSelectedItem();
|
|
369
|
+
|
|
370
|
+
if (item) this.onItemSelectionChanged?.(item);
|
|
371
|
+
}
|
|
372
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared surface colors, so panes leave a transparent terminal see-through.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import type { Theme } from "./theme.ts";
|
|
7
|
+
|
|
8
|
+
/** A pane's background, or none when the theme keeps the terminal's own. */
|
|
9
|
+
export const surfaceBackground = (theme: Theme): string =>
|
|
10
|
+
theme.transparent ? "transparent" : theme.bgElevated;
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal colors from Groundwork's tokens: the roles of one theme and mode,
|
|
3
|
+
* placed into the slots the terminal components use, and picked with
|
|
4
|
+
* `GW_THEME` and `GW_MODE` (Base and dark by default).
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { opentuiThemes } from "@timmo001/groundwork-tokens/opentui";
|
|
9
|
+
import { Config, Effect } from "effect";
|
|
10
|
+
|
|
11
|
+
/** Semantic color slots for the TUI */
|
|
12
|
+
export interface Theme {
|
|
13
|
+
/** App background */
|
|
14
|
+
readonly bg: string;
|
|
15
|
+
/** Elevated surface (pane/card background) */
|
|
16
|
+
readonly bgElevated: string;
|
|
17
|
+
/** Selected row background */
|
|
18
|
+
readonly bgSelected: string;
|
|
19
|
+
/** Focused input background */
|
|
20
|
+
readonly bgInput: string;
|
|
21
|
+
/** Accent for selections, highlights, cursors and emphasis text */
|
|
22
|
+
readonly accent: string;
|
|
23
|
+
/** Text on the accent */
|
|
24
|
+
readonly accentFg: string;
|
|
25
|
+
/** Secondary surface (separators, secondary selection background) */
|
|
26
|
+
readonly surface: string;
|
|
27
|
+
/** Primary foreground text */
|
|
28
|
+
readonly fg: string;
|
|
29
|
+
/** Muted secondary text */
|
|
30
|
+
readonly fgMuted: string;
|
|
31
|
+
/** Dim text for subtle UI elements */
|
|
32
|
+
readonly fgSubtle: string;
|
|
33
|
+
/** Dimmest text for ghost-level elements */
|
|
34
|
+
readonly fgGhost: string;
|
|
35
|
+
/** Success state */
|
|
36
|
+
readonly green: string;
|
|
37
|
+
/** Error state */
|
|
38
|
+
readonly red: string;
|
|
39
|
+
/** Warning or in-progress state */
|
|
40
|
+
readonly yellow: string;
|
|
41
|
+
/** Whether panels skip painting backgrounds to let terminal transparency through */
|
|
42
|
+
readonly transparent: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ThemeId = keyof typeof opentuiThemes;
|
|
46
|
+
|
|
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
|
+
});
|
|
67
|
+
|
|
68
|
+
const isTheme = (id: string): id is ThemeId => id in opentuiThemes;
|
|
69
|
+
|
|
70
|
+
/** Base, dark: the default when nothing is picked. */
|
|
71
|
+
export const DEFAULT_THEME: Theme = fromRoles(opentuiThemes.base.dark);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A core theme's slots in one mode, or Base and dark when there's no such
|
|
75
|
+
* theme or mode.
|
|
76
|
+
*/
|
|
77
|
+
export const themeFor = (id: string, mode: string): Theme => {
|
|
78
|
+
if (!isTheme(id)) return DEFAULT_THEME;
|
|
79
|
+
|
|
80
|
+
const roles = Object.entries(opentuiThemes[id]).find(
|
|
81
|
+
([name]) => name === mode,
|
|
82
|
+
)?.[1];
|
|
83
|
+
|
|
84
|
+
return roles === undefined ? DEFAULT_THEME : fromRoles(roles);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
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.
|
|
90
|
+
*/
|
|
91
|
+
export const loadTheme: Effect.Effect<Theme> = Effect.gen(function* () {
|
|
92
|
+
const id = yield* Config.String("GW_THEME").pipe(Config.withDefault("base"));
|
|
93
|
+
|
|
94
|
+
const mode = yield* Config.Literals(
|
|
95
|
+
["light", "dark", "darker"],
|
|
96
|
+
"GW_MODE",
|
|
97
|
+
).pipe(Config.withDefault("dark"));
|
|
98
|
+
|
|
99
|
+
return themeFor(id, mode);
|
|
100
|
+
}).pipe(Effect.orElseSucceed(() => DEFAULT_THEME));
|
package/src/tile.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tile: a compact rounded card with a title, an optional icon, and a
|
|
3
|
+
* secondary line whose parts are joined with dots. Selection fills it.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
BoxRenderable,
|
|
9
|
+
StyledText,
|
|
10
|
+
TextRenderable,
|
|
11
|
+
fg,
|
|
12
|
+
t,
|
|
13
|
+
} from "@opentui/core";
|
|
14
|
+
import type { CliRenderer, TextChunk } from "@opentui/core";
|
|
15
|
+
import type { Theme } from "./theme.ts";
|
|
16
|
+
|
|
17
|
+
export interface TileOptions {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** Primary text */
|
|
20
|
+
readonly primary: string;
|
|
21
|
+
/** Secondary text, or parts joined with ` • ` */
|
|
22
|
+
readonly secondary?: string | readonly string[] | undefined;
|
|
23
|
+
/** Nerd Font icon shown before the primary text */
|
|
24
|
+
readonly icon?: string | undefined;
|
|
25
|
+
/** 28 columns by default */
|
|
26
|
+
readonly width?: number;
|
|
27
|
+
readonly height?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A compact rounded card; add `box` to the parent. */
|
|
31
|
+
export class Tile {
|
|
32
|
+
/** The tile's card */
|
|
33
|
+
readonly box: BoxRenderable;
|
|
34
|
+
private readonly theme: Theme;
|
|
35
|
+
private readonly primaryText: TextRenderable;
|
|
36
|
+
private readonly secondaryText: TextRenderable | undefined;
|
|
37
|
+
private readonly icon: string | undefined;
|
|
38
|
+
private primary: string;
|
|
39
|
+
private secondary: string | readonly string[] | undefined;
|
|
40
|
+
private selected = false;
|
|
41
|
+
|
|
42
|
+
constructor(renderer: CliRenderer, theme: Theme, options: TileOptions) {
|
|
43
|
+
this.theme = theme;
|
|
44
|
+
this.primary = options.primary;
|
|
45
|
+
this.secondary = options.secondary;
|
|
46
|
+
this.icon = options.icon;
|
|
47
|
+
|
|
48
|
+
this.box = new BoxRenderable(renderer, {
|
|
49
|
+
id: options.id,
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
flexShrink: 0,
|
|
52
|
+
width: options.width ?? 28,
|
|
53
|
+
paddingX: 1,
|
|
54
|
+
borderStyle: "rounded",
|
|
55
|
+
borderColor: theme.accent,
|
|
56
|
+
shouldFill: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (options.height !== undefined) this.box.height = options.height;
|
|
60
|
+
|
|
61
|
+
this.primaryText = new TextRenderable(renderer, {
|
|
62
|
+
id: `${options.id}-primary`,
|
|
63
|
+
content: "",
|
|
64
|
+
flexGrow: 1,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (options.secondary === undefined) {
|
|
68
|
+
this.primaryText.marginTop = 1;
|
|
69
|
+
this.primaryText.marginBottom = 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.box.add(this.primaryText);
|
|
73
|
+
|
|
74
|
+
if (options.secondary !== undefined) {
|
|
75
|
+
this.secondaryText = new TextRenderable(renderer, {
|
|
76
|
+
id: `${options.id}-secondary`,
|
|
77
|
+
content: "",
|
|
78
|
+
marginTop: 1,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.box.add(this.secondaryText);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.paint();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setPrimary(primary: string): void {
|
|
88
|
+
this.primary = primary;
|
|
89
|
+
this.paint();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Changes the secondary line, when the tile was made with one. */
|
|
93
|
+
setSecondary(secondary: string | readonly string[]): void {
|
|
94
|
+
this.secondary = secondary;
|
|
95
|
+
this.paint();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setSelected(selected: boolean): void {
|
|
99
|
+
this.selected = selected;
|
|
100
|
+
this.box.shouldFill = selected;
|
|
101
|
+
this.box.backgroundColor = selected ? this.theme.bgSelected : "transparent";
|
|
102
|
+
this.paint();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private paint(): void {
|
|
106
|
+
const theme = this.theme;
|
|
107
|
+
const color = this.selected ? theme.accent : theme.fg;
|
|
108
|
+
|
|
109
|
+
this.primaryText.content = this.icon
|
|
110
|
+
? t`${fg(color)(this.icon)} ${fg(color)(this.primary)}`
|
|
111
|
+
: t`${fg(color)(this.primary)}`;
|
|
112
|
+
|
|
113
|
+
if (this.secondaryText === undefined || this.secondary === undefined)
|
|
114
|
+
return;
|
|
115
|
+
|
|
116
|
+
const chunks: TextChunk[] = [this.secondary]
|
|
117
|
+
.flat()
|
|
118
|
+
.flatMap((part, index) =>
|
|
119
|
+
index === 0
|
|
120
|
+
? [fg(theme.fgMuted)(part)]
|
|
121
|
+
: [fg(theme.fgSubtle)(" • "), fg(theme.fgMuted)(part)],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
this.secondaryText.content = new StyledText(chunks);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/toast.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toast: one short message in the top-right corner that closes itself.
|
|
3
|
+
* Showing another with the same id replaces it in place, and a new id
|
|
4
|
+
* replaces the old toast, so toasts never stack. Use one only when nothing
|
|
5
|
+
* on screen can show the result, such as a command run in the background.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { BoxRenderable, TextRenderable, bold, fg, t } from "@opentui/core";
|
|
10
|
+
import type { CliRenderer } from "@opentui/core";
|
|
11
|
+
import type { Theme } from "./theme.ts";
|
|
12
|
+
|
|
13
|
+
export type ToastVariant = "info" | "success" | "error";
|
|
14
|
+
|
|
15
|
+
const DISMISS_MS: Readonly<Record<ToastVariant, number>> = {
|
|
16
|
+
info: 5000,
|
|
17
|
+
success: 3000,
|
|
18
|
+
error: 8000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ICON: Readonly<Record<ToastVariant, string>> = {
|
|
22
|
+
info: "",
|
|
23
|
+
success: "",
|
|
24
|
+
error: "",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const variantColor = (theme: Theme, variant: ToastVariant): string => {
|
|
28
|
+
switch (variant) {
|
|
29
|
+
case "info":
|
|
30
|
+
return theme.accent;
|
|
31
|
+
case "success":
|
|
32
|
+
return theme.green;
|
|
33
|
+
case "error":
|
|
34
|
+
return theme.red;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A single-slot toast. */
|
|
39
|
+
export class Toast {
|
|
40
|
+
private readonly theme: Theme;
|
|
41
|
+
private readonly root: BoxRenderable;
|
|
42
|
+
private readonly text: TextRenderable;
|
|
43
|
+
private currentId: string | undefined;
|
|
44
|
+
private timeout: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(renderer: CliRenderer, theme: Theme, id = "toast") {
|
|
47
|
+
this.theme = theme;
|
|
48
|
+
|
|
49
|
+
this.root = new BoxRenderable(renderer, {
|
|
50
|
+
id,
|
|
51
|
+
position: "absolute",
|
|
52
|
+
top: 1,
|
|
53
|
+
right: 2,
|
|
54
|
+
width: 50,
|
|
55
|
+
height: 3,
|
|
56
|
+
borderStyle: "rounded",
|
|
57
|
+
borderColor: theme.accent,
|
|
58
|
+
backgroundColor: theme.bgElevated,
|
|
59
|
+
paddingLeft: 1,
|
|
60
|
+
paddingRight: 1,
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
zIndex: 200,
|
|
63
|
+
visible: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.text = new TextRenderable(renderer, {
|
|
67
|
+
id: `${id}-text`,
|
|
68
|
+
content: "",
|
|
69
|
+
wrapMode: "none",
|
|
70
|
+
truncate: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.root.add(this.text);
|
|
74
|
+
renderer.root.add(this.root);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The id of the toast showing, if any. */
|
|
78
|
+
get id(): string | undefined {
|
|
79
|
+
return this.currentId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Shows a message, replacing whatever toast is showing. */
|
|
83
|
+
show(id: string, message: string, variant: ToastVariant): void {
|
|
84
|
+
clearTimeout(this.timeout);
|
|
85
|
+
|
|
86
|
+
const color = variantColor(this.theme, variant);
|
|
87
|
+
|
|
88
|
+
this.currentId = id;
|
|
89
|
+
this.root.borderColor = color;
|
|
90
|
+
this.text.content = t`${fg(color)(ICON[variant])} ${bold(fg(this.theme.fg)(message))}`;
|
|
91
|
+
this.root.visible = true;
|
|
92
|
+
this.timeout = setTimeout(() => this.dismiss(), DISMISS_MS[variant]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Hides the toast. */
|
|
96
|
+
dismiss(): void {
|
|
97
|
+
clearTimeout(this.timeout);
|
|
98
|
+
this.timeout = undefined;
|
|
99
|
+
this.currentId = undefined;
|
|
100
|
+
this.root.visible = false;
|
|
101
|
+
}
|
|
102
|
+
}
|