@timmo001/groundwork-tui 0.0.0-stage → 0.0.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.
- package/LICENSE +201 -0
- package/README.md +29 -2
- package/package.json +42 -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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The radio group: a focusable list of two-line choices, the label and a
|
|
3
|
+
* description, where one is always picked. Arrow keys move the pick, Enter or
|
|
4
|
+
* Space activates it, and a click picks a choice.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { BoxRenderable, TextRenderable, bold, fg, t } from "@opentui/core";
|
|
9
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
10
|
+
import type { Theme } from "./theme.ts";
|
|
11
|
+
|
|
12
|
+
export interface RadioChoice<Value extends string> {
|
|
13
|
+
readonly value: Value;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly description?: string | undefined;
|
|
16
|
+
/** The picked label's color; the accent by default */
|
|
17
|
+
readonly color?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RadioGroupOptions<Value extends string> {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly theme: Theme;
|
|
23
|
+
/** At least one */
|
|
24
|
+
readonly choices: readonly [RadioChoice<Value>, ...RadioChoice<Value>[]];
|
|
25
|
+
readonly value: Value;
|
|
26
|
+
/** Called when the pick moves */
|
|
27
|
+
readonly onValueChange?: ((value: Value) => void) | undefined;
|
|
28
|
+
/** Called on Enter or Space */
|
|
29
|
+
readonly onActivate?: ((value: Value) => void) | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A focusable group of choices with one always picked. */
|
|
33
|
+
export class RadioGroup<Value extends string> extends BoxRenderable {
|
|
34
|
+
private readonly theme: Theme;
|
|
35
|
+
private readonly choices: readonly [
|
|
36
|
+
RadioChoice<Value>,
|
|
37
|
+
...RadioChoice<Value>[],
|
|
38
|
+
];
|
|
39
|
+
private readonly rows: readonly TextRenderable[];
|
|
40
|
+
private readonly onValueChange: ((value: Value) => void) | undefined;
|
|
41
|
+
private readonly onActivate: ((value: Value) => void) | undefined;
|
|
42
|
+
private selectedIndex: number;
|
|
43
|
+
|
|
44
|
+
constructor(renderer: CliRenderer, options: RadioGroupOptions<Value>) {
|
|
45
|
+
super(renderer, {
|
|
46
|
+
id: options.id,
|
|
47
|
+
flexDirection: "column",
|
|
48
|
+
width: "100%",
|
|
49
|
+
height: options.choices.length * 2,
|
|
50
|
+
flexShrink: 0,
|
|
51
|
+
focusable: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.theme = options.theme;
|
|
55
|
+
this.choices = options.choices;
|
|
56
|
+
this.onValueChange = options.onValueChange;
|
|
57
|
+
this.onActivate = options.onActivate;
|
|
58
|
+
|
|
59
|
+
this.selectedIndex = Math.max(
|
|
60
|
+
0,
|
|
61
|
+
options.choices.findIndex((choice) => choice.value === options.value),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.rows = options.choices.map((choice, index) => {
|
|
65
|
+
const row = new TextRenderable(renderer, {
|
|
66
|
+
id: `${options.id}-${choice.value}`,
|
|
67
|
+
content: "",
|
|
68
|
+
height: 2,
|
|
69
|
+
flexShrink: 0,
|
|
70
|
+
wrapMode: "none",
|
|
71
|
+
truncate: true,
|
|
72
|
+
onMouseDown: (event) => {
|
|
73
|
+
if (event.button !== 0) return;
|
|
74
|
+
|
|
75
|
+
this.focus();
|
|
76
|
+
this.select(index);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.add(row);
|
|
81
|
+
|
|
82
|
+
return row;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.refresh();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The picked choice's value. */
|
|
89
|
+
get value(): Value {
|
|
90
|
+
return (this.choices[this.selectedIndex] ?? this.choices[0]).value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Picks a choice without calling `onValueChange`. */
|
|
94
|
+
set value(value: Value) {
|
|
95
|
+
const index = this.choices.findIndex((choice) => choice.value === value);
|
|
96
|
+
|
|
97
|
+
if (index >= 0) this.select(index, false);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
override handleKeyPress(key: KeyEvent): boolean {
|
|
101
|
+
const count = this.choices.length;
|
|
102
|
+
|
|
103
|
+
switch (key.name) {
|
|
104
|
+
case "up":
|
|
105
|
+
case "left":
|
|
106
|
+
this.select((this.selectedIndex - 1 + count) % count);
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
case "down":
|
|
110
|
+
case "right":
|
|
111
|
+
this.select((this.selectedIndex + 1) % count);
|
|
112
|
+
|
|
113
|
+
return true;
|
|
114
|
+
case "return":
|
|
115
|
+
case "space":
|
|
116
|
+
this.onActivate?.(this.value);
|
|
117
|
+
|
|
118
|
+
return true;
|
|
119
|
+
default:
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private select(index: number, emit = true): void {
|
|
125
|
+
this.selectedIndex = index;
|
|
126
|
+
this.refresh();
|
|
127
|
+
|
|
128
|
+
if (emit) this.onValueChange?.(this.value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private refresh(): void {
|
|
132
|
+
for (const [index, row] of this.rows.entries()) {
|
|
133
|
+
const choice = this.choices[index] ?? this.choices[0];
|
|
134
|
+
const picked = index === this.selectedIndex;
|
|
135
|
+
|
|
136
|
+
const color = picked
|
|
137
|
+
? (choice.color ?? this.theme.accent)
|
|
138
|
+
: this.theme.fgMuted;
|
|
139
|
+
|
|
140
|
+
const label = `${picked ? "●" : "○"} ${choice.label}`;
|
|
141
|
+
const description = ` ${choice.description ?? ""}`;
|
|
142
|
+
|
|
143
|
+
row.content = t`${picked ? bold(fg(color)(label)) : fg(color)(label)}\n${fg(this.theme.fgSubtle)(description)}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scroll surface: a scroll box with a one-column position marker in place
|
|
3
|
+
* of the native scrollbar, so the gutter never changes the content's width.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
BoxRenderable,
|
|
9
|
+
ScrollBoxRenderable,
|
|
10
|
+
TextRenderable,
|
|
11
|
+
fg,
|
|
12
|
+
t,
|
|
13
|
+
} from "@opentui/core";
|
|
14
|
+
import type { CliRenderer, ScrollBoxOptions } from "@opentui/core";
|
|
15
|
+
import type { Theme } from "./theme.ts";
|
|
16
|
+
|
|
17
|
+
export interface ScrollSurfaceOptions extends ScrollBoxOptions {
|
|
18
|
+
readonly theme: Theme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Draws the marker column: a thumb of `┃` on a track of `│`. */
|
|
22
|
+
const renderMarker = (
|
|
23
|
+
viewport: number,
|
|
24
|
+
extent: number,
|
|
25
|
+
offset: number,
|
|
26
|
+
): string => {
|
|
27
|
+
if (viewport <= 0) return "";
|
|
28
|
+
|
|
29
|
+
if (extent === 0)
|
|
30
|
+
return Array.from({ length: viewport }, () => " ").join("\n");
|
|
31
|
+
|
|
32
|
+
const thumb = Math.max(
|
|
33
|
+
1,
|
|
34
|
+
Math.floor((viewport * viewport) / (viewport + extent)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const start = Math.round((offset / extent) * Math.max(0, viewport - thumb));
|
|
38
|
+
|
|
39
|
+
return Array.from({ length: viewport }, (_, row) =>
|
|
40
|
+
row >= start && row < start + thumb ? "┃" : "│",
|
|
41
|
+
).join("\n");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A scroll box with a fixed marker column and no native scrollbar. */
|
|
45
|
+
export class ScrollSurface extends BoxRenderable {
|
|
46
|
+
readonly scrollBox: ScrollBoxRenderable;
|
|
47
|
+
private readonly marker: TextRenderable;
|
|
48
|
+
private readonly surfaceTheme: Theme;
|
|
49
|
+
private markerText = "";
|
|
50
|
+
private markerMetrics = "";
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
renderer: CliRenderer,
|
|
54
|
+
{ theme, width, ...options }: ScrollSurfaceOptions,
|
|
55
|
+
) {
|
|
56
|
+
const outer = {
|
|
57
|
+
id: options.id,
|
|
58
|
+
height: options.height,
|
|
59
|
+
flexGrow: options.flexGrow,
|
|
60
|
+
flexShrink: options.flexShrink,
|
|
61
|
+
minHeight: options.minHeight,
|
|
62
|
+
backgroundColor: options.backgroundColor,
|
|
63
|
+
focusable: options.focusable,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
super(renderer, {
|
|
67
|
+
...Object.fromEntries(
|
|
68
|
+
Object.entries(outer).filter(([, value]) => value !== undefined),
|
|
69
|
+
),
|
|
70
|
+
flexDirection: "row",
|
|
71
|
+
width: width ?? "100%",
|
|
72
|
+
});
|
|
73
|
+
this.surfaceTheme = theme;
|
|
74
|
+
|
|
75
|
+
this.scrollBox = new ScrollBoxRenderable(renderer, {
|
|
76
|
+
...options,
|
|
77
|
+
id: `${options.id}-scrollbox`,
|
|
78
|
+
flexGrow: 1,
|
|
79
|
+
minWidth: 0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.scrollBox.renderAfter = () => this.syncMarker();
|
|
83
|
+
this.scrollBox.verticalScrollBar.visible = false;
|
|
84
|
+
|
|
85
|
+
this.marker = new TextRenderable(renderer, {
|
|
86
|
+
id: `${options.id}-marker`,
|
|
87
|
+
width: 1,
|
|
88
|
+
height: "100%",
|
|
89
|
+
flexShrink: 0,
|
|
90
|
+
content: "",
|
|
91
|
+
wrapMode: "none",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.add(this.scrollBox);
|
|
95
|
+
this.add(this.marker);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Adds a child to the scrolled content. */
|
|
99
|
+
addContent(renderable: Parameters<ScrollBoxRenderable["add"]>[0]): number {
|
|
100
|
+
return this.scrollBox.add(renderable);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override focus(): void {
|
|
104
|
+
this.scrollBox.focus();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override blur(): void {
|
|
108
|
+
this.scrollBox.blur();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override handleKeyPress(
|
|
112
|
+
key: Parameters<ScrollBoxRenderable["handleKeyPress"]>[0],
|
|
113
|
+
): boolean {
|
|
114
|
+
const handled = this.scrollBox.handleKeyPress(key);
|
|
115
|
+
this.syncMarker();
|
|
116
|
+
|
|
117
|
+
return handled;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Redraws the marker when the viewport, content or offset changed. */
|
|
121
|
+
syncMarker(): void {
|
|
122
|
+
const viewport = this.scrollBox.viewport.height;
|
|
123
|
+
|
|
124
|
+
if (viewport <= 0) return;
|
|
125
|
+
|
|
126
|
+
const extent = Math.max(0, this.scrollBox.scrollHeight - viewport);
|
|
127
|
+
const offset = Math.max(0, Math.min(this.scrollBox.scrollTop, extent));
|
|
128
|
+
const metrics = `${viewport}:${extent}:${offset}`;
|
|
129
|
+
|
|
130
|
+
if (metrics === this.markerMetrics) return;
|
|
131
|
+
|
|
132
|
+
this.markerMetrics = metrics;
|
|
133
|
+
const marker = renderMarker(viewport, extent, offset);
|
|
134
|
+
|
|
135
|
+
if (marker === this.markerText) return;
|
|
136
|
+
|
|
137
|
+
this.markerText = marker;
|
|
138
|
+
this.marker.content = t`${fg(this.surfaceTheme.fgSubtle)(marker)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
protected override onUpdate(deltaTime: number): void {
|
|
142
|
+
super.onUpdate(deltaTime);
|
|
143
|
+
|
|
144
|
+
if (this.markerMetrics) this.syncMarker();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The section heading: a full-width row with a dimmed bold title and an
|
|
3
|
+
* optional icon, which starts a new row in a wrapping tile grid.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { BoxRenderable, TextRenderable, bold, fg, t } from "@opentui/core";
|
|
8
|
+
import type { CliRenderer } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "./theme.ts";
|
|
10
|
+
|
|
11
|
+
/** Width of the optional left icon column in characters */
|
|
12
|
+
const ICON_COLUMN_WIDTH = 4;
|
|
13
|
+
|
|
14
|
+
export interface SectionHeadingOptions {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
/** Nerd Font icon shown before the title */
|
|
18
|
+
readonly icon?: string | undefined;
|
|
19
|
+
readonly marginTop?: number;
|
|
20
|
+
/** 1 by default */
|
|
21
|
+
readonly marginBottom?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A full-width heading row; add `box` to the parent. */
|
|
25
|
+
export class SectionHeading {
|
|
26
|
+
/** The heading's row */
|
|
27
|
+
readonly box: BoxRenderable;
|
|
28
|
+
private readonly theme: Theme;
|
|
29
|
+
private readonly titleText: TextRenderable;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
renderer: CliRenderer,
|
|
33
|
+
theme: Theme,
|
|
34
|
+
options: SectionHeadingOptions,
|
|
35
|
+
) {
|
|
36
|
+
this.theme = theme;
|
|
37
|
+
|
|
38
|
+
this.box = new BoxRenderable(renderer, {
|
|
39
|
+
id: options.id,
|
|
40
|
+
flexDirection: "row",
|
|
41
|
+
width: "100%",
|
|
42
|
+
flexShrink: 0,
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
shouldFill: false,
|
|
45
|
+
marginTop: options.marginTop ?? 0,
|
|
46
|
+
marginBottom: options.marginBottom ?? 1,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (options.icon) {
|
|
50
|
+
const iconCol = new BoxRenderable(renderer, {
|
|
51
|
+
id: `${options.id}-icol`,
|
|
52
|
+
width: ICON_COLUMN_WIDTH,
|
|
53
|
+
paddingLeft: 1,
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
iconCol.add(
|
|
58
|
+
new TextRenderable(renderer, {
|
|
59
|
+
id: `${options.id}-icon`,
|
|
60
|
+
content: t`${fg(theme.fgSubtle)(options.icon)}`,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.box.add(iconCol);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.titleText = new TextRenderable(renderer, {
|
|
68
|
+
id: `${options.id}-title`,
|
|
69
|
+
content: "",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.box.add(this.titleText);
|
|
73
|
+
this.setTitle(options.title);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setTitle(title: string): void {
|
|
77
|
+
this.titleText.content = t`${bold(fg(this.theme.fgSubtle)(title))}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setVisible(visible: boolean): void {
|
|
81
|
+
this.box.visible = visible;
|
|
82
|
+
}
|
|
83
|
+
}
|