@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collapsible: a one-row trigger with a ▸ or ▾ marker over a panel that
|
|
3
|
+
* shows only while open. Enter, Space or a click on the trigger toggles it.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { BoxRenderable, TextRenderable, fg, t } from "@opentui/core";
|
|
8
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "./theme.ts";
|
|
10
|
+
|
|
11
|
+
export interface CollapsibleOptions {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly theme: Theme;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly open: boolean;
|
|
16
|
+
readonly onOpenChange?: ((open: boolean) => void) | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A focusable trigger over a panel; add the panel's content to `panel`. */
|
|
20
|
+
export class Collapsible extends BoxRenderable {
|
|
21
|
+
/** Holds the content shown while open */
|
|
22
|
+
readonly panel: BoxRenderable;
|
|
23
|
+
private readonly theme: Theme;
|
|
24
|
+
private readonly trigger: TextRenderable;
|
|
25
|
+
private readonly onOpenChange: ((open: boolean) => void) | undefined;
|
|
26
|
+
private label: string;
|
|
27
|
+
private isOpen: boolean;
|
|
28
|
+
|
|
29
|
+
constructor(renderer: CliRenderer, options: CollapsibleOptions) {
|
|
30
|
+
super(renderer, {
|
|
31
|
+
id: options.id,
|
|
32
|
+
flexDirection: "column",
|
|
33
|
+
width: "100%",
|
|
34
|
+
flexShrink: 0,
|
|
35
|
+
focusable: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.theme = options.theme;
|
|
39
|
+
this.onOpenChange = options.onOpenChange;
|
|
40
|
+
this.label = options.label;
|
|
41
|
+
this.isOpen = options.open;
|
|
42
|
+
|
|
43
|
+
this.trigger = new TextRenderable(renderer, {
|
|
44
|
+
id: `${options.id}-trigger`,
|
|
45
|
+
content: "",
|
|
46
|
+
height: 1,
|
|
47
|
+
flexShrink: 0,
|
|
48
|
+
wrapMode: "none",
|
|
49
|
+
truncate: true,
|
|
50
|
+
onMouseDown: (event) => {
|
|
51
|
+
if (event.button !== 0) return;
|
|
52
|
+
|
|
53
|
+
this.focus();
|
|
54
|
+
this.toggle();
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.panel = new BoxRenderable(renderer, {
|
|
59
|
+
id: `${options.id}-panel`,
|
|
60
|
+
flexDirection: "column",
|
|
61
|
+
width: "100%",
|
|
62
|
+
flexShrink: 0,
|
|
63
|
+
paddingLeft: 2,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.add(this.trigger);
|
|
67
|
+
this.add(this.panel);
|
|
68
|
+
this.refresh();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the panel shows. */
|
|
72
|
+
get open(): boolean {
|
|
73
|
+
return this.isOpen;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Opens or closes the panel without calling `onOpenChange`. */
|
|
77
|
+
set open(open: boolean) {
|
|
78
|
+
this.isOpen = open;
|
|
79
|
+
this.refresh();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Changes the trigger's label. */
|
|
83
|
+
setLabel(label: string): void {
|
|
84
|
+
this.label = label;
|
|
85
|
+
this.refresh();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override focus(): void {
|
|
89
|
+
super.focus();
|
|
90
|
+
this.refresh();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override blur(): void {
|
|
94
|
+
super.blur();
|
|
95
|
+
this.refresh();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override handleKeyPress(key: KeyEvent): boolean {
|
|
99
|
+
if (key.name !== "return" && key.name !== "space") return false;
|
|
100
|
+
|
|
101
|
+
this.toggle();
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private toggle(): void {
|
|
107
|
+
this.open = !this.isOpen;
|
|
108
|
+
this.onOpenChange?.(this.isOpen);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private refresh(): void {
|
|
112
|
+
if (this.isDestroyed || this.trigger.isDestroyed) return;
|
|
113
|
+
|
|
114
|
+
const color = this.focused ? this.theme.accent : this.theme.fgMuted;
|
|
115
|
+
|
|
116
|
+
this.trigger.content = t`${fg(color)(`${this.isOpen ? "▾" : "▸"} ${this.label}`)}`;
|
|
117
|
+
this.panel.visible = this.isOpen;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The confirm dialog: a question with Cancel and Confirm buttons. Y or the
|
|
3
|
+
* Confirm button confirms; Escape, a click outside or Cancel closes it.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { BoxRenderable, TextRenderable, fg, t } from "@opentui/core";
|
|
8
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core";
|
|
9
|
+
import { Button } from "./button.ts";
|
|
10
|
+
import { Dialog } from "./dialog.ts";
|
|
11
|
+
import type { Theme } from "./theme.ts";
|
|
12
|
+
|
|
13
|
+
export interface ConfirmDialogOptions {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly theme: Theme;
|
|
16
|
+
/** For a destructive action: the red border and Confirm button */
|
|
17
|
+
readonly destructive?: boolean | undefined;
|
|
18
|
+
/** "Confirm" by default */
|
|
19
|
+
readonly confirmLabel?: string | undefined;
|
|
20
|
+
readonly onConfirm: () => void;
|
|
21
|
+
/** Runs when it closes without confirming */
|
|
22
|
+
readonly onCancel?: (() => void) | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A yes or no question in a dialog. */
|
|
26
|
+
export class ConfirmDialog {
|
|
27
|
+
private readonly renderer: CliRenderer;
|
|
28
|
+
private readonly dialog: Dialog;
|
|
29
|
+
private readonly theme: Theme;
|
|
30
|
+
private readonly message: TextRenderable;
|
|
31
|
+
private readonly onConfirm: () => void;
|
|
32
|
+
|
|
33
|
+
constructor(renderer: CliRenderer, options: ConfirmDialogOptions) {
|
|
34
|
+
this.renderer = renderer;
|
|
35
|
+
this.theme = options.theme;
|
|
36
|
+
this.onConfirm = options.onConfirm;
|
|
37
|
+
|
|
38
|
+
this.dialog = new Dialog(renderer, {
|
|
39
|
+
id: options.id,
|
|
40
|
+
theme: options.theme,
|
|
41
|
+
title: "",
|
|
42
|
+
color: options.destructive === true ? options.theme.red : undefined,
|
|
43
|
+
height: 8,
|
|
44
|
+
help: [
|
|
45
|
+
{ key: "y", action: "confirm" },
|
|
46
|
+
{ key: "Tab", action: "move" },
|
|
47
|
+
{ key: "Esc", action: "cancel" },
|
|
48
|
+
],
|
|
49
|
+
onClose: options.onCancel,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.message = new TextRenderable(renderer, {
|
|
53
|
+
id: `${options.id}-message`,
|
|
54
|
+
content: "",
|
|
55
|
+
flexGrow: 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const buttons = new BoxRenderable(renderer, {
|
|
59
|
+
id: `${options.id}-buttons`,
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
justifyContent: "flex-end",
|
|
62
|
+
gap: 2,
|
|
63
|
+
flexShrink: 0,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const cancel = new Button(renderer, {
|
|
67
|
+
id: `${options.id}-cancel`,
|
|
68
|
+
theme: options.theme,
|
|
69
|
+
label: "Cancel",
|
|
70
|
+
onPress: () => this.dialog.close(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const confirm = new Button(renderer, {
|
|
74
|
+
id: `${options.id}-confirm`,
|
|
75
|
+
theme: options.theme,
|
|
76
|
+
label: options.confirmLabel ?? "Confirm",
|
|
77
|
+
variant: options.destructive === true ? "destructive" : "primary",
|
|
78
|
+
onPress: () => this.confirm(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
buttons.add(cancel);
|
|
82
|
+
buttons.add(confirm);
|
|
83
|
+
this.dialog.body.add(this.message);
|
|
84
|
+
this.dialog.body.add(buttons);
|
|
85
|
+
this.dialog.setFocusables([cancel, confirm]);
|
|
86
|
+
|
|
87
|
+
renderer.keyInput.prependListener("keypress", this.onKey);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether it's open. */
|
|
91
|
+
get visible(): boolean {
|
|
92
|
+
return this.dialog.visible;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Asks the question; Cancel has focus, so Enter alone never confirms. */
|
|
96
|
+
show(title: string, message: string): void {
|
|
97
|
+
this.dialog.setTitle(title);
|
|
98
|
+
this.message.content = t`${fg(this.theme.fg)(message)}`;
|
|
99
|
+
this.dialog.show();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Removes the dialog. */
|
|
103
|
+
destroy(): void {
|
|
104
|
+
this.renderer.keyInput.off("keypress", this.onKey);
|
|
105
|
+
this.dialog.destroy();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private confirm(): void {
|
|
109
|
+
this.dialog.hide();
|
|
110
|
+
this.onConfirm();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private readonly onKey = (key: KeyEvent): void => {
|
|
114
|
+
if (!this.dialog.visible || key.name !== "y" || key.ctrl || key.meta)
|
|
115
|
+
return;
|
|
116
|
+
|
|
117
|
+
key.preventDefault();
|
|
118
|
+
key.stopPropagation();
|
|
119
|
+
this.confirm();
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/dialog.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dialog: a centered, bordered panel over the current view, with a
|
|
3
|
+
* title, an optional description, a body and a key hint line. Only one is
|
|
4
|
+
* open at a time: opening another closes the first. Escape or a click outside
|
|
5
|
+
* closes it, and `onClose` runs once per close.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { BoxRenderable, TextRenderable, bold, fg, t } from "@opentui/core";
|
|
10
|
+
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core";
|
|
11
|
+
import { formatHelpBar } from "./help-bar.ts";
|
|
12
|
+
import type { HelpEntry } from "./help-bar.ts";
|
|
13
|
+
import type { Theme } from "./theme.ts";
|
|
14
|
+
|
|
15
|
+
export interface DialogOptions {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly theme: Theme;
|
|
18
|
+
readonly title: string;
|
|
19
|
+
readonly description?: string | undefined;
|
|
20
|
+
/** The border and title color; the accent by default */
|
|
21
|
+
readonly color?: string | undefined;
|
|
22
|
+
/** 58 by default, and never wider than the terminal */
|
|
23
|
+
readonly width?: number | undefined;
|
|
24
|
+
/** 14 by default, and never taller than the terminal */
|
|
25
|
+
readonly height?: number | undefined;
|
|
26
|
+
/** The key hints along the bottom; Escape to close by default */
|
|
27
|
+
readonly help?: readonly HelpEntry[] | undefined;
|
|
28
|
+
/** Runs once each time the dialog closes, however it closed */
|
|
29
|
+
readonly onClose?: (() => void) | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLOSE_HELP: readonly HelpEntry[] = [{ key: "Esc", action: "close" }];
|
|
33
|
+
|
|
34
|
+
/** One dialog at a time; add its content to `body`. */
|
|
35
|
+
export class Dialog {
|
|
36
|
+
private static open: Dialog | undefined;
|
|
37
|
+
/** Holds the dialog's content */
|
|
38
|
+
readonly body: BoxRenderable;
|
|
39
|
+
private readonly renderer: CliRenderer;
|
|
40
|
+
private readonly options: DialogOptions;
|
|
41
|
+
private readonly overlay: BoxRenderable;
|
|
42
|
+
private readonly panel: BoxRenderable;
|
|
43
|
+
private readonly titleText: TextRenderable;
|
|
44
|
+
private readonly helpText: TextRenderable;
|
|
45
|
+
private focusables: readonly Renderable[] = [];
|
|
46
|
+
private previousFocus: Renderable | undefined;
|
|
47
|
+
private height: number;
|
|
48
|
+
|
|
49
|
+
constructor(renderer: CliRenderer, options: DialogOptions) {
|
|
50
|
+
this.renderer = renderer;
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.height = options.height ?? 14;
|
|
53
|
+
|
|
54
|
+
const color = options.color ?? options.theme.accent;
|
|
55
|
+
|
|
56
|
+
this.overlay = new BoxRenderable(renderer, {
|
|
57
|
+
id: options.id,
|
|
58
|
+
position: "absolute",
|
|
59
|
+
width: "100%",
|
|
60
|
+
height: "100%",
|
|
61
|
+
zIndex: 160,
|
|
62
|
+
visible: false,
|
|
63
|
+
onMouseDown: (event) => {
|
|
64
|
+
if (event.target === this.overlay) this.close();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.panel = new BoxRenderable(renderer, {
|
|
69
|
+
id: `${options.id}-panel`,
|
|
70
|
+
position: "absolute",
|
|
71
|
+
flexDirection: "column",
|
|
72
|
+
borderStyle: "rounded",
|
|
73
|
+
borderColor: color,
|
|
74
|
+
backgroundColor: options.theme.bgElevated,
|
|
75
|
+
paddingLeft: 1,
|
|
76
|
+
paddingRight: 1,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.titleText = new TextRenderable(renderer, {
|
|
80
|
+
id: `${options.id}-title`,
|
|
81
|
+
content: t`${bold(fg(color)(options.title))}`,
|
|
82
|
+
height: 1,
|
|
83
|
+
flexShrink: 0,
|
|
84
|
+
wrapMode: "none",
|
|
85
|
+
truncate: true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.panel.add(this.titleText);
|
|
89
|
+
|
|
90
|
+
if (options.description !== undefined) {
|
|
91
|
+
this.panel.add(
|
|
92
|
+
new TextRenderable(renderer, {
|
|
93
|
+
id: `${options.id}-description`,
|
|
94
|
+
content: t`${fg(options.theme.fgMuted)(options.description)}`,
|
|
95
|
+
flexShrink: 0,
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.body = new BoxRenderable(renderer, {
|
|
101
|
+
id: `${options.id}-body`,
|
|
102
|
+
flexDirection: "column",
|
|
103
|
+
width: "100%",
|
|
104
|
+
flexGrow: 1,
|
|
105
|
+
minHeight: 0,
|
|
106
|
+
marginTop: 1,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.helpText = new TextRenderable(renderer, {
|
|
110
|
+
id: `${options.id}-help`,
|
|
111
|
+
content: formatHelpBar(options.theme, options.help ?? CLOSE_HELP),
|
|
112
|
+
height: 1,
|
|
113
|
+
flexShrink: 0,
|
|
114
|
+
wrapMode: "none",
|
|
115
|
+
truncate: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.panel.add(this.body);
|
|
119
|
+
this.panel.add(this.helpText);
|
|
120
|
+
this.overlay.add(this.panel);
|
|
121
|
+
renderer.root.add(this.overlay);
|
|
122
|
+
|
|
123
|
+
renderer.keyInput.prependListener("keypress", this.onKey);
|
|
124
|
+
renderer.on("resize", this.onResize);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether this dialog is open. */
|
|
128
|
+
get visible(): boolean {
|
|
129
|
+
return this.overlay.visible;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Changes the title. */
|
|
133
|
+
setTitle(title: string): void {
|
|
134
|
+
const color = this.options.color ?? this.options.theme.accent;
|
|
135
|
+
|
|
136
|
+
this.titleText.content = t`${bold(fg(color)(title))}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Changes the height, such as to fit a new list. */
|
|
140
|
+
setHeight(height: number): void {
|
|
141
|
+
this.height = height;
|
|
142
|
+
|
|
143
|
+
if (this.visible) this.measure();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sets what Tab and Shift+Tab move between, in order. The first gets focus
|
|
148
|
+
* when the dialog opens.
|
|
149
|
+
*/
|
|
150
|
+
setFocusables(focusables: readonly Renderable[]): void {
|
|
151
|
+
this.focusables = focusables;
|
|
152
|
+
|
|
153
|
+
if (this.visible) this.focusables[0]?.focus();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Opens the dialog, closing any other that's open. */
|
|
157
|
+
show(): void {
|
|
158
|
+
if (this.visible) return;
|
|
159
|
+
|
|
160
|
+
Dialog.open?.close();
|
|
161
|
+
Dialog.open = this;
|
|
162
|
+
|
|
163
|
+
this.previousFocus = this.renderer.currentFocusedRenderable ?? undefined;
|
|
164
|
+
this.previousFocus?.blur();
|
|
165
|
+
this.measure();
|
|
166
|
+
this.overlay.visible = true;
|
|
167
|
+
this.focusables[0]?.focus();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Closes the dialog, gives focus back and runs `onClose`. */
|
|
171
|
+
close(): void {
|
|
172
|
+
if (!this.visible) return;
|
|
173
|
+
|
|
174
|
+
this.hide();
|
|
175
|
+
this.options.onClose?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Closes the dialog without running `onClose`, such as after a choice. */
|
|
179
|
+
hide(): void {
|
|
180
|
+
if (!this.visible) return;
|
|
181
|
+
|
|
182
|
+
this.overlay.visible = false;
|
|
183
|
+
|
|
184
|
+
for (const focusable of this.focusables) focusable.blur();
|
|
185
|
+
|
|
186
|
+
if (Dialog.open === this) Dialog.open = undefined;
|
|
187
|
+
|
|
188
|
+
this.previousFocus?.focus();
|
|
189
|
+
this.previousFocus = undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Removes the dialog and stops listening for keys. */
|
|
193
|
+
destroy(): void {
|
|
194
|
+
this.hide();
|
|
195
|
+
this.renderer.keyInput.off("keypress", this.onKey);
|
|
196
|
+
this.renderer.off("resize", this.onResize);
|
|
197
|
+
this.renderer.root.remove(this.overlay);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private readonly onKey = (key: KeyEvent): void => {
|
|
201
|
+
if (!this.visible) return;
|
|
202
|
+
|
|
203
|
+
if (key.name === "escape") {
|
|
204
|
+
key.preventDefault();
|
|
205
|
+
key.stopPropagation();
|
|
206
|
+
this.close();
|
|
207
|
+
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (key.name !== "tab" || this.focusables.length === 0) return;
|
|
212
|
+
|
|
213
|
+
key.preventDefault();
|
|
214
|
+
key.stopPropagation();
|
|
215
|
+
|
|
216
|
+
const count = this.focusables.length;
|
|
217
|
+
const current = this.renderer.currentFocusedRenderable;
|
|
218
|
+
|
|
219
|
+
const index = Math.max(
|
|
220
|
+
0,
|
|
221
|
+
this.focusables.findIndex((focusable) => focusable === current),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
this.focusables[index]?.blur();
|
|
225
|
+
this.focusables[(index + (key.shift ? count - 1 : 1)) % count]?.focus();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
private readonly onResize = (): void => {
|
|
229
|
+
if (this.visible) this.measure();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
private measure(): void {
|
|
233
|
+
const width = Math.max(
|
|
234
|
+
1,
|
|
235
|
+
Math.min(this.options.width ?? 58, this.renderer.width - 4),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const height = Math.max(1, Math.min(this.height, this.renderer.height - 2));
|
|
239
|
+
|
|
240
|
+
this.panel.width = width;
|
|
241
|
+
this.panel.height = height;
|
|
242
|
+
this.panel.left = Math.max(
|
|
243
|
+
0,
|
|
244
|
+
Math.floor((this.renderer.width - width) / 2),
|
|
245
|
+
);
|
|
246
|
+
this.panel.top = Math.max(
|
|
247
|
+
0,
|
|
248
|
+
Math.floor((this.renderer.height - height) / 2),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filter bar: a dimmed `/` when there's no filter, or an accented `/`
|
|
3
|
+
* and the filter text while filtering.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { fg, t } from "@opentui/core";
|
|
8
|
+
import type { StyledText } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "./theme.ts";
|
|
10
|
+
|
|
11
|
+
/** Formats the filter bar for the current filter text. */
|
|
12
|
+
export const formatFilterBar = (theme: Theme, filter: string): StyledText =>
|
|
13
|
+
filter.length === 0
|
|
14
|
+
? t`${fg(theme.fgSubtle)("/")}`
|
|
15
|
+
: t`${fg(theme.accent)("/")} ${fg(theme.fg)(filter)}`;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header bar: one line with the breadcrumb title, a status dot and label,
|
|
3
|
+
* and details on the right that drop off when the terminal is too narrow.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { StyledText, bold, fg } from "@opentui/core";
|
|
8
|
+
import type { TextChunk } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "./theme.ts";
|
|
10
|
+
|
|
11
|
+
/** How the status reads: working, waiting, failed or off. */
|
|
12
|
+
export type HeaderTone = "ok" | "pending" | "error" | "off";
|
|
13
|
+
|
|
14
|
+
/** What the header bar shows after the title. */
|
|
15
|
+
export interface HeaderStatus {
|
|
16
|
+
readonly tone: HeaderTone;
|
|
17
|
+
/** Such as "Connected" or "Syncing" */
|
|
18
|
+
readonly label: string;
|
|
19
|
+
/**
|
|
20
|
+
* Shown dimmed after the label, in order, until the line runs out of
|
|
21
|
+
* room, such as the host or the signed-in user.
|
|
22
|
+
*/
|
|
23
|
+
readonly details?: readonly string[] | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEPARATOR = " › ";
|
|
27
|
+
|
|
28
|
+
const GAP = " ";
|
|
29
|
+
|
|
30
|
+
const STATUS_DOT: Readonly<Record<HeaderTone, string>> = {
|
|
31
|
+
ok: "●",
|
|
32
|
+
pending: "◌",
|
|
33
|
+
error: "✗",
|
|
34
|
+
off: "○",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const toneColor = (theme: Theme, tone: HeaderTone): string => {
|
|
38
|
+
switch (tone) {
|
|
39
|
+
case "ok":
|
|
40
|
+
return theme.green;
|
|
41
|
+
case "pending":
|
|
42
|
+
return theme.yellow;
|
|
43
|
+
case "error":
|
|
44
|
+
return theme.red;
|
|
45
|
+
case "off":
|
|
46
|
+
return theme.fgSubtle;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Formats the header bar to fit `width` columns: the title parts as a
|
|
52
|
+
* breadcrumb (earlier parts dimmed, the last bold in the accent), the status,
|
|
53
|
+
* then as many details as fit.
|
|
54
|
+
*/
|
|
55
|
+
export const formatHeaderBar = (
|
|
56
|
+
theme: Theme,
|
|
57
|
+
titleParts: readonly string[],
|
|
58
|
+
status: HeaderStatus,
|
|
59
|
+
width: number,
|
|
60
|
+
): StyledText => {
|
|
61
|
+
const last = titleParts.at(-1) ?? "";
|
|
62
|
+
const prefix = titleParts.slice(0, -1).join(SEPARATOR);
|
|
63
|
+
|
|
64
|
+
const title: TextChunk[] =
|
|
65
|
+
prefix.length === 0
|
|
66
|
+
? [bold(fg(theme.accent)(last))]
|
|
67
|
+
: [
|
|
68
|
+
fg(theme.fgMuted)(prefix),
|
|
69
|
+
fg(theme.fgSubtle)(SEPARATOR),
|
|
70
|
+
bold(fg(theme.accent)(last)),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const color = toneColor(theme, status.tone);
|
|
74
|
+
const dot = STATUS_DOT[status.tone];
|
|
75
|
+
|
|
76
|
+
const chunks: TextChunk[] = [
|
|
77
|
+
...title,
|
|
78
|
+
fg(theme.fgSubtle)(GAP),
|
|
79
|
+
fg(color)(`${dot} ${status.label}`),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
let used = chunks.reduce((total, chunk) => total + chunk.text.length, 0);
|
|
83
|
+
|
|
84
|
+
for (const detail of status.details ?? []) {
|
|
85
|
+
used += GAP.length + detail.length;
|
|
86
|
+
|
|
87
|
+
if (used > width - 1) break;
|
|
88
|
+
|
|
89
|
+
chunks.push(fg(theme.fgSubtle)(GAP), fg(theme.fgMuted)(detail));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new StyledText(chunks);
|
|
93
|
+
};
|