@tiagojct/subsub 0.20.0 → 0.22.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/CHANGELOG.md +21 -0
- package/README.md +8 -1
- package/dist/chrome.js +171 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/rpc.js +185 -0
- package/dist/screens.js +425 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +198 -31
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +16 -216
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/rpc.ts +198 -0
- package/src/screens.ts +530 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +214 -36
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +14 -209
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
package/src/screens.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notes and library screens of the terminal look, as pi-tui components for `ctx.ui.custom()`. Each has a pure function that draws it as
|
|
3
|
+
* lines (snapshots at 40, 80 and 120 columns) and a class that holds the state and the keys. The notes screen has two columns from 100 columns
|
|
4
|
+
* up (the list, and the note under the cursor); below that it is a list, and Enter opens a note.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { cut, evidenceLine, fill, rule } from "./chrome.ts";
|
|
9
|
+
import type { Paint } from "./look.ts";
|
|
10
|
+
import { frontMatter, field } from "./notes.ts";
|
|
11
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
12
|
+
|
|
13
|
+
type Lang = "en" | "pt";
|
|
14
|
+
|
|
15
|
+
export const SCREEN_WORDS = {
|
|
16
|
+
en: {
|
|
17
|
+
notes: "Notes", of: "of", filter: "filter", none: "No notes yet. Sub-Sub writes them in Literature/, Research/ and Syntheses/.", noMatch: "No note matches.", empty: "(empty note)",
|
|
18
|
+
hintNotes: "up and down: choose type: filter PgUp PgDn: scroll ctrl+o: open Esc: close", hintRead: "up and down: scroll ctrl+o: open Esc: back",
|
|
19
|
+
library: "Library", typeToSearch: "Type to search your library.", searching: "Searching...", noItems: "Nothing found.", failed: "The search failed",
|
|
20
|
+
hintLibrary: "up and down: choose Enter: read ctrl+n: note Esc: close", hintLibrarian: "up and down: choose Enter: copy the key Esc: close",
|
|
21
|
+
hintChoice: "up and down: choose Enter: select type: filter Esc: cancel", noChoice: "Nothing matches.", hintInput: "Enter: send Esc: cancel",
|
|
22
|
+
},
|
|
23
|
+
pt: {
|
|
24
|
+
notes: "Notas", of: "de", filter: "filtro", none: "Ainda não há notas. O Sub-Sub escreve-as em Literature/, Research/ e Syntheses/.", noMatch: "Nenhuma nota corresponde.", empty: "(nota vazia)",
|
|
25
|
+
hintNotes: "cima e baixo: escolher escrever: filtrar PgUp PgDn: deslocar ctrl+o: abrir Esc: fechar", hintRead: "cima e baixo: deslocar ctrl+o: abrir Esc: voltar",
|
|
26
|
+
library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
|
|
27
|
+
hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
|
|
28
|
+
hintChoice: "cima e baixo: escolher Enter: selecionar escrever: filtrar Esc: cancelar", noChoice: "Nada corresponde.", hintInput: "Enter: enviar Esc: cancelar",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const LEFT = 36;
|
|
33
|
+
const WIDE = 100;
|
|
34
|
+
|
|
35
|
+
function pad(s: string, width: number): string {
|
|
36
|
+
return s + " ".repeat(Math.max(0, width - visibleWidth(s)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A key that types text (a letter, a digit, a pasted string), not a control or escape sequence. */
|
|
40
|
+
export function printable(data: string): boolean {
|
|
41
|
+
return data.length > 0 && !/[\x00-\x1f\x7f]/.test(data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------- notes
|
|
45
|
+
|
|
46
|
+
export interface NotesLayout {
|
|
47
|
+
width: number;
|
|
48
|
+
/** Lines for the whole screen. */
|
|
49
|
+
rows: number;
|
|
50
|
+
lang: Lang;
|
|
51
|
+
paint: Paint;
|
|
52
|
+
glyphs?: Glyphs;
|
|
53
|
+
mode: "list" | "read";
|
|
54
|
+
/** Paths of the notes that match the filter, relative to the Sub-Sub folder. */
|
|
55
|
+
entries: string[];
|
|
56
|
+
total: number;
|
|
57
|
+
selected: number;
|
|
58
|
+
filter: string;
|
|
59
|
+
evidence?: string;
|
|
60
|
+
/** The chosen note as lines, for a width. */
|
|
61
|
+
renderBody: (width: number) => string[];
|
|
62
|
+
bodyScroll: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function notesLines(o: NotesLayout): { lines: string[]; bodyMax: number } {
|
|
66
|
+
const g = o.glyphs ?? UNICODE;
|
|
67
|
+
const p = o.paint;
|
|
68
|
+
const w = Math.max(20, o.width);
|
|
69
|
+
const words = SCREEN_WORDS[o.lang];
|
|
70
|
+
const rows = Math.max(3, o.rows - 5);
|
|
71
|
+
const wide = w >= WIDE && o.mode === "list";
|
|
72
|
+
const count = o.entries.length === o.total ? `${o.total}` : `${o.entries.length} ${words.of} ${o.total}`;
|
|
73
|
+
const out = [p.mark(cut(` ${words.notes}`, w)) + p.muted(cut(` (${count})${o.filter ? ` ${words.filter}: ${o.filter}` : ""}`, Math.max(0, w - words.notes.length - 1))), rule(w, "solid", p.muted, g)];
|
|
74
|
+
let bodyMax = 0;
|
|
75
|
+
const hint = (text: string) => wrapTextWithAnsi(` ${text}`, w).map((l) => p.muted(l));
|
|
76
|
+
if (o.total === 0 || o.entries.length === 0) {
|
|
77
|
+
out.push(p.muted(cut(` ${o.total === 0 ? words.none : words.noMatch}`, w)), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
78
|
+
return { lines: out, bodyMax };
|
|
79
|
+
}
|
|
80
|
+
const list = (width: number): string[] => {
|
|
81
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(rows / 2), o.entries.length - rows));
|
|
82
|
+
return o.entries.slice(start, start + rows).map((rel, k) => {
|
|
83
|
+
const chosen = start + k === o.selected;
|
|
84
|
+
return chosen ? `${p.mark(g.pointer)} ${p.text(cut(rel, width - 2))}` : ` ${p.muted(cut(rel, width - 2))}`;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const body = (width: number): string[] => {
|
|
88
|
+
const lines = o.renderBody(Math.max(10, width));
|
|
89
|
+
const head = o.evidence ? [evidenceLine(o.evidence, o.lang, width, p, g), ""] : [];
|
|
90
|
+
const room = Math.max(1, rows - head.length);
|
|
91
|
+
bodyMax = Math.max(0, lines.length - room);
|
|
92
|
+
const from = Math.min(o.bodyScroll, bodyMax);
|
|
93
|
+
const shown = lines.slice(from, from + room).map((l) => truncateToWidth(l, width));
|
|
94
|
+
return [...head, ...(shown.length ? shown : [p.dim(words.empty)])];
|
|
95
|
+
};
|
|
96
|
+
if (wide) {
|
|
97
|
+
const right = w - LEFT - 3;
|
|
98
|
+
const left = list(LEFT);
|
|
99
|
+
const detail = body(right);
|
|
100
|
+
for (let i = 0; i < rows; i++) out.push(`${pad(left[i] ?? "", LEFT)} ${p.muted(g.vertical)} ${detail[i] ?? ""}`);
|
|
101
|
+
out.push(rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
102
|
+
} else if (o.mode === "list") {
|
|
103
|
+
out.push(...list(w), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
104
|
+
} else {
|
|
105
|
+
out.push(...body(w), rule(w, "solid", p.muted, g), ...hint(words.hintRead));
|
|
106
|
+
}
|
|
107
|
+
return { lines: out, bodyMax };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface NotesOptions {
|
|
111
|
+
/** The notes, newest first. */
|
|
112
|
+
notes: Array<{ path: string; rel: string }>;
|
|
113
|
+
read: (path: string) => string;
|
|
114
|
+
/** The text of a note as lines, for a width (pi's Markdown component in the terminal). */
|
|
115
|
+
markdown: (text: string, width: number) => string[];
|
|
116
|
+
open: (path: string) => void;
|
|
117
|
+
done: () => void;
|
|
118
|
+
lang: Lang;
|
|
119
|
+
paint: Paint;
|
|
120
|
+
glyphs?: Glyphs;
|
|
121
|
+
rows?: () => number;
|
|
122
|
+
redraw?: () => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class NotesView {
|
|
126
|
+
private readonly o: NotesOptions;
|
|
127
|
+
private filter = "";
|
|
128
|
+
private selected = 0;
|
|
129
|
+
private mode: "list" | "read" = "list";
|
|
130
|
+
private bodyScroll = 0;
|
|
131
|
+
private bodyMax = 0;
|
|
132
|
+
private lastWidth = 80;
|
|
133
|
+
private readonly texts = new Map<string, string>();
|
|
134
|
+
constructor(o: NotesOptions) {
|
|
135
|
+
this.o = o;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private entries() {
|
|
139
|
+
const f = this.filter.toLowerCase();
|
|
140
|
+
return f ? this.o.notes.filter((n) => n.rel.toLowerCase().includes(f)) : this.o.notes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private text(): string {
|
|
144
|
+
const n = this.entries()[this.selected];
|
|
145
|
+
if (!n) return "";
|
|
146
|
+
let t = this.texts.get(n.path);
|
|
147
|
+
if (t === undefined) {
|
|
148
|
+
try {
|
|
149
|
+
t = this.o.read(n.path);
|
|
150
|
+
} catch {
|
|
151
|
+
t = "";
|
|
152
|
+
}
|
|
153
|
+
this.texts.set(n.path, t);
|
|
154
|
+
}
|
|
155
|
+
return t;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
render(width: number): string[] {
|
|
159
|
+
this.lastWidth = width;
|
|
160
|
+
const text = this.text();
|
|
161
|
+
const front = frontMatter(text);
|
|
162
|
+
const evidence = front ? field(front, "evidence") : undefined;
|
|
163
|
+
const body = front ? text.slice(text.indexOf("\n---", 3) + 4).replace(/^\r?\n/, "") : text;
|
|
164
|
+
const entries = this.entries();
|
|
165
|
+
const view = notesLines({
|
|
166
|
+
width, rows: this.height(), lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs, mode: this.mode, entries: entries.map((n) => n.rel), total: this.o.notes.length,
|
|
167
|
+
selected: this.selected, filter: this.filter, evidence, renderBody: (w) => this.o.markdown(body, w), bodyScroll: this.bodyScroll,
|
|
168
|
+
});
|
|
169
|
+
this.bodyMax = view.bodyMax;
|
|
170
|
+
this.bodyScroll = Math.min(this.bodyScroll, this.bodyMax);
|
|
171
|
+
return fill(view.lines, width, this.height());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The screen covers the terminal, but for one line. */
|
|
175
|
+
private height(): number {
|
|
176
|
+
return Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
invalidate(): void {}
|
|
180
|
+
|
|
181
|
+
private move(by: number): void {
|
|
182
|
+
const n = this.entries().length;
|
|
183
|
+
if (!n) return;
|
|
184
|
+
this.selected = Math.min(n - 1, Math.max(0, this.selected + by));
|
|
185
|
+
this.bodyScroll = 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
handleInput(data: string): void {
|
|
189
|
+
const page = Math.max(3, this.height() - 6);
|
|
190
|
+
const wideList = this.lastWidth >= WIDE && this.mode === "list";
|
|
191
|
+
const scrolls = this.mode === "read" || wideList;
|
|
192
|
+
if (matchesKey(data, "ctrl+c")) return this.o.done();
|
|
193
|
+
if (matchesKey(data, "escape")) {
|
|
194
|
+
if (this.mode === "read") this.mode = "list";
|
|
195
|
+
else if (this.filter) {
|
|
196
|
+
this.filter = "";
|
|
197
|
+
this.selected = 0;
|
|
198
|
+
} else return this.o.done();
|
|
199
|
+
} else if (matchesKey(data, "ctrl+o")) {
|
|
200
|
+
const n = this.entries()[this.selected];
|
|
201
|
+
if (n) this.o.open(n.path);
|
|
202
|
+
return;
|
|
203
|
+
} else if (matchesKey(data, "enter")) {
|
|
204
|
+
if (this.mode === "list" && !wideList && this.entries().length) {
|
|
205
|
+
this.mode = "read";
|
|
206
|
+
this.bodyScroll = 0;
|
|
207
|
+
}
|
|
208
|
+
} else if (matchesKey(data, "up")) {
|
|
209
|
+
if (this.mode === "read") this.bodyScroll = Math.max(0, this.bodyScroll - 1);
|
|
210
|
+
else this.move(-1);
|
|
211
|
+
} else if (matchesKey(data, "down")) {
|
|
212
|
+
if (this.mode === "read") this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + 1);
|
|
213
|
+
else this.move(1);
|
|
214
|
+
} else if (matchesKey(data, "pageUp")) {
|
|
215
|
+
if (scrolls) this.bodyScroll = Math.max(0, this.bodyScroll - page);
|
|
216
|
+
else this.move(-page);
|
|
217
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
218
|
+
if (scrolls) this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + page);
|
|
219
|
+
else this.move(page);
|
|
220
|
+
} else if (this.mode === "list" && matchesKey(data, "backspace")) {
|
|
221
|
+
this.filter = this.filter.slice(0, -1);
|
|
222
|
+
this.selected = 0;
|
|
223
|
+
} else if (this.mode === "list" && printable(data)) {
|
|
224
|
+
this.filter += data;
|
|
225
|
+
this.selected = 0;
|
|
226
|
+
this.bodyScroll = 0;
|
|
227
|
+
} else return;
|
|
228
|
+
this.o.redraw?.();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------- library
|
|
233
|
+
|
|
234
|
+
export interface LibraryItem {
|
|
235
|
+
key: string;
|
|
236
|
+
citekey?: string;
|
|
237
|
+
title?: string;
|
|
238
|
+
authors?: string;
|
|
239
|
+
year?: string | number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface LibraryLayout {
|
|
243
|
+
width: number;
|
|
244
|
+
rows: number;
|
|
245
|
+
lang: Lang;
|
|
246
|
+
paint: Paint;
|
|
247
|
+
glyphs?: Glyphs;
|
|
248
|
+
researcher: boolean;
|
|
249
|
+
query: string;
|
|
250
|
+
items: LibraryItem[];
|
|
251
|
+
selected: number;
|
|
252
|
+
state: "idle" | "searching" | "done" | "failed";
|
|
253
|
+
error?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function libraryLines(o: LibraryLayout): string[] {
|
|
257
|
+
const g = o.glyphs ?? UNICODE;
|
|
258
|
+
const p = o.paint;
|
|
259
|
+
const w = Math.max(20, o.width);
|
|
260
|
+
const words = SCREEN_WORDS[o.lang];
|
|
261
|
+
const room = Math.max(2, o.rows - 5);
|
|
262
|
+
const out = [p.mark(cut(` ${words.library}`, w)), `${p.muted(" > ")}${p.text(cut(o.query, w - 4))}${p.mark("_")}`, rule(w, "solid", p.muted, g)];
|
|
263
|
+
if (o.state === "idle" && !o.query) out.push(p.muted(cut(` ${words.typeToSearch}`, w)));
|
|
264
|
+
else if (o.state === "searching") out.push(p.muted(cut(` ${words.searching}`, w)));
|
|
265
|
+
else if (o.state === "failed") out.push(p.text(cut(` ${g.hollow} ${words.failed}${o.error ? `: ${o.error.split("\n")[0]}` : ""}`, w)));
|
|
266
|
+
else if (!o.items.length) out.push(p.muted(cut(` ${words.noItems}`, w)));
|
|
267
|
+
else {
|
|
268
|
+
const per = 2;
|
|
269
|
+
const fit = Math.max(1, Math.floor(room / per));
|
|
270
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
|
|
271
|
+
for (const [k, it] of o.items.slice(start, start + fit).entries()) {
|
|
272
|
+
const chosen = start + k === o.selected;
|
|
273
|
+
const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
|
|
274
|
+
out.push(chosen ? `${p.mark(g.pointer)} ${p.text(cut(it.title || it.key, w - 2))}` : ` ${p.muted(cut(it.title || it.key, w - 2))}`, ` ${p.dim(cut(meta, w - 2))}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${o.researcher ? words.hintLibrary : words.hintLibrarian}`, w).map((l) => p.muted(l)));
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface LibraryOptions {
|
|
282
|
+
search: (query: string) => Promise<LibraryItem[]>;
|
|
283
|
+
/** Enter: the researcher reads the item, the librarian gets its key. */
|
|
284
|
+
choose: (item: LibraryItem) => void;
|
|
285
|
+
note: (item: LibraryItem) => void;
|
|
286
|
+
done: () => void;
|
|
287
|
+
lang: Lang;
|
|
288
|
+
paint: Paint;
|
|
289
|
+
glyphs?: Glyphs;
|
|
290
|
+
researcher: boolean;
|
|
291
|
+
rows?: () => number;
|
|
292
|
+
redraw?: () => void;
|
|
293
|
+
/** Milliseconds to wait after the last key before searching. */
|
|
294
|
+
delay?: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class LibraryView {
|
|
298
|
+
private readonly o: LibraryOptions;
|
|
299
|
+
private query = "";
|
|
300
|
+
private items: LibraryItem[] = [];
|
|
301
|
+
private selected = 0;
|
|
302
|
+
private state: LibraryLayout["state"] = "idle";
|
|
303
|
+
private error: string | undefined;
|
|
304
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
305
|
+
private seq = 0;
|
|
306
|
+
constructor(o: LibraryOptions) {
|
|
307
|
+
this.o = o;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
render(width: number): string[] {
|
|
311
|
+
const height = Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
312
|
+
return fill(
|
|
313
|
+
libraryLines({
|
|
314
|
+
width, rows: height, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs, researcher: this.o.researcher,
|
|
315
|
+
query: this.query, items: this.items, selected: this.selected, state: this.state, error: this.error,
|
|
316
|
+
}),
|
|
317
|
+
width,
|
|
318
|
+
height,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
invalidate(): void {}
|
|
323
|
+
|
|
324
|
+
dispose(): void {
|
|
325
|
+
clearTimeout(this.timer);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private schedule(): void {
|
|
329
|
+
clearTimeout(this.timer);
|
|
330
|
+
const q = this.query.replace(/\s+/g, " ").trim();
|
|
331
|
+
const mine = ++this.seq;
|
|
332
|
+
if (!q) {
|
|
333
|
+
this.items = [];
|
|
334
|
+
this.state = "idle";
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.state = "searching";
|
|
338
|
+
this.timer = setTimeout(async () => {
|
|
339
|
+
try {
|
|
340
|
+
const items = await this.o.search(q);
|
|
341
|
+
if (mine !== this.seq) return; // a newer search is on its way
|
|
342
|
+
this.items = items;
|
|
343
|
+
this.state = "done";
|
|
344
|
+
this.selected = 0;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (mine !== this.seq) return;
|
|
347
|
+
this.state = "failed";
|
|
348
|
+
this.error = (err as Error).message;
|
|
349
|
+
}
|
|
350
|
+
this.o.redraw?.();
|
|
351
|
+
}, this.o.delay ?? 250);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
handleInput(data: string): void {
|
|
355
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.o.done();
|
|
356
|
+
const item = this.items[this.selected];
|
|
357
|
+
if (matchesKey(data, "enter")) {
|
|
358
|
+
if (item) this.o.choose(item);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (matchesKey(data, "ctrl+n")) {
|
|
362
|
+
if (item && this.o.researcher) this.o.note(item);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
366
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(Math.max(0, this.items.length - 1), this.selected + 1);
|
|
367
|
+
else if (matchesKey(data, "backspace")) {
|
|
368
|
+
this.query = this.query.slice(0, -1);
|
|
369
|
+
this.schedule();
|
|
370
|
+
} else if (printable(data)) {
|
|
371
|
+
this.query += data;
|
|
372
|
+
this.schedule();
|
|
373
|
+
} else return;
|
|
374
|
+
this.o.redraw?.();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------- a list to choose from
|
|
379
|
+
|
|
380
|
+
export interface Choice {
|
|
381
|
+
label: string;
|
|
382
|
+
/** Dim text after the label (a command, a date). */
|
|
383
|
+
hint?: string;
|
|
384
|
+
value: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface ChoiceLayout {
|
|
388
|
+
title: string;
|
|
389
|
+
choices: Choice[];
|
|
390
|
+
selected: number;
|
|
391
|
+
filter: string;
|
|
392
|
+
width: number;
|
|
393
|
+
rows: number;
|
|
394
|
+
lang: Lang;
|
|
395
|
+
paint: Paint;
|
|
396
|
+
glyphs?: Glyphs;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function choiceLines(o: ChoiceLayout): string[] {
|
|
400
|
+
const g = o.glyphs ?? UNICODE;
|
|
401
|
+
const p = o.paint;
|
|
402
|
+
const w = Math.max(20, o.width);
|
|
403
|
+
const words = SCREEN_WORDS[o.lang];
|
|
404
|
+
const room = Math.max(1, o.rows - 4);
|
|
405
|
+
const out = [p.mark(cut(` ${o.title}`, w)) + (o.filter ? p.muted(cut(` ${words.filter}: ${o.filter}`, Math.max(0, w - o.title.length - 1))) : ""), rule(w, "solid", p.muted, g)];
|
|
406
|
+
if (!o.choices.length) out.push(p.muted(cut(` ${words.noChoice}`, w)));
|
|
407
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(room / 2), o.choices.length - room));
|
|
408
|
+
for (const [k, c] of o.choices.slice(start, start + room).entries()) {
|
|
409
|
+
const chosen = start + k === o.selected;
|
|
410
|
+
const hint = c.hint ? ` ${c.hint}` : "";
|
|
411
|
+
const label = cut(c.label, Math.max(8, w - 2 - hint.length));
|
|
412
|
+
out.push(`${chosen ? p.mark(g.pointer) : " "} ${chosen ? p.text(label) : p.muted(label)}${p.dim(cut(hint, Math.max(0, w - 2 - label.length)))}`);
|
|
413
|
+
}
|
|
414
|
+
out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${words.hintChoice}`, w).map((l) => p.muted(l)));
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface ChoiceOptions {
|
|
419
|
+
title: string;
|
|
420
|
+
choices: Choice[];
|
|
421
|
+
choose: (value: string) => void;
|
|
422
|
+
cancel: () => void;
|
|
423
|
+
lang: Lang;
|
|
424
|
+
paint: Paint;
|
|
425
|
+
glyphs?: Glyphs;
|
|
426
|
+
rows?: () => number;
|
|
427
|
+
redraw?: () => void;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export class ChoiceView {
|
|
431
|
+
private readonly o: ChoiceOptions;
|
|
432
|
+
private filter = "";
|
|
433
|
+
private selected = 0;
|
|
434
|
+
private answered = false;
|
|
435
|
+
constructor(o: ChoiceOptions) {
|
|
436
|
+
this.o = o;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private shown(): Choice[] {
|
|
440
|
+
const f = this.filter.toLowerCase();
|
|
441
|
+
return f ? this.o.choices.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(f)) : this.o.choices;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
render(width: number): string[] {
|
|
445
|
+
const rows = Math.max(8, Math.floor(((this.o.rows?.() ?? 24) * 8) / 10));
|
|
446
|
+
return fill(choiceLines({ title: this.o.title, choices: this.shown(), selected: this.selected, filter: this.filter, width, rows, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
invalidate(): void {}
|
|
450
|
+
|
|
451
|
+
handleInput(data: string): void {
|
|
452
|
+
if (this.answered) return;
|
|
453
|
+
const list = this.shown();
|
|
454
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
455
|
+
this.answered = true;
|
|
456
|
+
return this.o.cancel();
|
|
457
|
+
}
|
|
458
|
+
if (matchesKey(data, "enter")) {
|
|
459
|
+
const c = list[this.selected];
|
|
460
|
+
if (!c) return;
|
|
461
|
+
this.answered = true;
|
|
462
|
+
return this.o.choose(c.value);
|
|
463
|
+
}
|
|
464
|
+
if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
465
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
|
|
466
|
+
else if (matchesKey(data, "backspace")) {
|
|
467
|
+
this.filter = this.filter.slice(0, -1);
|
|
468
|
+
this.selected = 0;
|
|
469
|
+
} else if (printable(data)) {
|
|
470
|
+
this.filter += data;
|
|
471
|
+
this.selected = 0;
|
|
472
|
+
} else return;
|
|
473
|
+
this.o.redraw?.();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------- a question with a typed answer
|
|
478
|
+
|
|
479
|
+
export interface InputOptions {
|
|
480
|
+
title: string;
|
|
481
|
+
placeholder?: string;
|
|
482
|
+
prefill?: string;
|
|
483
|
+
submit: (text: string) => void;
|
|
484
|
+
cancel: () => void;
|
|
485
|
+
lang: Lang;
|
|
486
|
+
paint: Paint;
|
|
487
|
+
glyphs?: Glyphs;
|
|
488
|
+
redraw?: () => void;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function inputLines(o: { title: string; placeholder?: string; text: string; width: number; lang: Lang; paint: Paint; glyphs?: Glyphs }): string[] {
|
|
492
|
+
const g = o.glyphs ?? UNICODE;
|
|
493
|
+
const p = o.paint;
|
|
494
|
+
const w = Math.max(20, o.width);
|
|
495
|
+
const shown = o.text ? p.text(cut(o.text, w - 5)) + p.mark("_") : p.dim(cut(o.placeholder ?? "", w - 5)) + p.mark("_");
|
|
496
|
+
return [p.mark(cut(` ${o.title}`, w)), rule(w, "solid", p.muted, g), ` ${p.muted(">")} ${shown}`, rule(w, "solid", p.muted, g), p.muted(cut(` ${SCREEN_WORDS[o.lang].hintInput}`, w))];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export class InputView {
|
|
500
|
+
private readonly o: InputOptions;
|
|
501
|
+
private text: string;
|
|
502
|
+
private answered = false;
|
|
503
|
+
constructor(o: InputOptions) {
|
|
504
|
+
this.o = o;
|
|
505
|
+
this.text = o.prefill ?? "";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
render(width: number): string[] {
|
|
509
|
+
return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text: this.text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
invalidate(): void {}
|
|
513
|
+
|
|
514
|
+
handleInput(data: string): void {
|
|
515
|
+
if (this.answered) return;
|
|
516
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
517
|
+
this.answered = true;
|
|
518
|
+
return this.o.cancel();
|
|
519
|
+
}
|
|
520
|
+
if (matchesKey(data, "enter")) {
|
|
521
|
+
this.answered = true;
|
|
522
|
+
return this.o.submit(this.text);
|
|
523
|
+
}
|
|
524
|
+
if (matchesKey(data, "backspace")) this.text = this.text.slice(0, -1);
|
|
525
|
+
else if (matchesKey(data, "ctrl+u")) this.text = "";
|
|
526
|
+
else if (printable(data)) this.text += data;
|
|
527
|
+
else return;
|
|
528
|
+
this.o.redraw?.();
|
|
529
|
+
}
|
|
530
|
+
}
|
package/src/skin.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skin of the terminal screens: the paint roles drawn from a pi theme, and the glyphs. Meaning is never carried by
|
|
3
|
+
* colour alone: a rule is solid, dashed or dotted, a failure has a hollow mark and a word, and a terminal that cannot show
|
|
4
|
+
* the box-drawing glyphs (a non-UTF-8 locale, or SUBSUB_ASCII=1) gets plain ASCII.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Paint } from "./look.ts";
|
|
8
|
+
|
|
9
|
+
export interface Glyphs {
|
|
10
|
+
/** A solid rule: full text, done. */
|
|
11
|
+
solid: string;
|
|
12
|
+
/** A dashed rule: an abstract, a preview, pending. */
|
|
13
|
+
dashed: string;
|
|
14
|
+
/** A dotted rule: metadata only. */
|
|
15
|
+
dotted: string;
|
|
16
|
+
/** The hollow mark of a failure or of something that is not running. */
|
|
17
|
+
hollow: string;
|
|
18
|
+
/** A filled and an empty cell of the context meter. */
|
|
19
|
+
cell: string;
|
|
20
|
+
cellEmpty: string;
|
|
21
|
+
/** The line between two columns, and the mark in front of the chosen row of a list. */
|
|
22
|
+
vertical: string;
|
|
23
|
+
pointer: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const UNICODE: Glyphs = { solid: "─", dashed: "┄", dotted: "┈", hollow: "○", cell: "▰", cellEmpty: "▱", vertical: "│", pointer: "›" };
|
|
27
|
+
export const ASCII: Glyphs = { solid: "=", dashed: "-", dotted: ".", hollow: "o", cell: "#", cellEmpty: "-", vertical: "|", pointer: ">" };
|
|
28
|
+
|
|
29
|
+
/** ASCII when SUBSUB_ASCII is set, or when the locale is C, POSIX or names a character set that is not UTF-8 (pt_PT.ISO-8859-1). No locale, or one without a character set, means UTF-8. */
|
|
30
|
+
export function useAscii(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
31
|
+
if (env.SUBSUB_ASCII && env.SUBSUB_ASCII !== "0") return true;
|
|
32
|
+
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
|
|
33
|
+
if (!locale || /utf-?8/i.test(locale)) return false;
|
|
34
|
+
return locale === "C" || locale === "POSIX" || /\.[^.]+$/.test(locale);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function glyphs(env: NodeJS.ProcessEnv = process.env): Glyphs {
|
|
38
|
+
return useAscii(env) ? ASCII : UNICODE;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The part of pi's Theme that the skin uses. */
|
|
42
|
+
export interface ThemeLike {
|
|
43
|
+
fg(role: string, text: string): string;
|
|
44
|
+
strikethrough?(text: string): string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Paint roles from the colours of a pi theme (themes/*.json): accent, text, muted, dim, success (sap) and warning (gamboge). */
|
|
48
|
+
export function paintFromTheme(th: ThemeLike): Paint {
|
|
49
|
+
return {
|
|
50
|
+
mark: (x) => th.fg("accent", x),
|
|
51
|
+
text: (x) => th.fg("text", x),
|
|
52
|
+
muted: (x) => th.fg("muted", x),
|
|
53
|
+
dim: (x) => th.fg("dim", x),
|
|
54
|
+
ok: (x) => th.fg("success", x),
|
|
55
|
+
pend: (x) => th.fg("warning", x),
|
|
56
|
+
// A removal has no colour of its own: muted ink, struck through, and the "-" in front stays for terminals that ignore the strike.
|
|
57
|
+
strike: (x) => (th.strikethrough ? th.strikethrough(th.fg("muted", x)) : th.fg("muted", x)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Frames of the working indicator: dotted, dashed, solid, dashed. One static frame when motion is reduced (SUBSUB_REDUCE_MOTION=1). */
|
|
62
|
+
export function workingFrames(g: Glyphs, reduceMotion = false): string[] {
|
|
63
|
+
const dashed = g.dashed.repeat(4);
|
|
64
|
+
return reduceMotion ? [dashed] : [g.dotted.repeat(4), dashed, g.solid.repeat(4), dashed];
|
|
65
|
+
}
|