@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/dist/rpc.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi in RPC mode, for the clients that show Sub-Sub: the web view (src/web.ts) and the full-screen terminal client (src/tui.ts). One child
|
|
3
|
+
* process, strict JSONL (records end at LF only), requests answered by id, every other record handed to the listeners. Sub-Sub's extension
|
|
4
|
+
* tells its clients what pi's RPC protocol has no place for through two status texts: the state of the library ("subsub.web") and the
|
|
5
|
+
* answer to a library search ("subsub.find").
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { StringDecoder } from "node:string_decoder";
|
|
10
|
+
import { field, frontMatter } from "./notes.js";
|
|
11
|
+
/** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
|
|
12
|
+
export class Agent {
|
|
13
|
+
proc;
|
|
14
|
+
decoder = new StringDecoder("utf8");
|
|
15
|
+
buf = "";
|
|
16
|
+
n = 0;
|
|
17
|
+
pending = new Map();
|
|
18
|
+
listeners = new Set();
|
|
19
|
+
stderr = "";
|
|
20
|
+
onExit;
|
|
21
|
+
command;
|
|
22
|
+
args;
|
|
23
|
+
cwd;
|
|
24
|
+
env;
|
|
25
|
+
constructor(command, args, cwd, env) {
|
|
26
|
+
this.command = command;
|
|
27
|
+
this.args = args;
|
|
28
|
+
this.cwd = cwd;
|
|
29
|
+
this.env = env;
|
|
30
|
+
}
|
|
31
|
+
start(extra = []) {
|
|
32
|
+
this.buf = "";
|
|
33
|
+
this.decoder = new StringDecoder("utf8");
|
|
34
|
+
const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
35
|
+
this.proc = proc;
|
|
36
|
+
proc.stdout.on("data", (chunk) => this.feed(this.decoder.write(chunk)));
|
|
37
|
+
proc.stderr.on("data", (d) => {
|
|
38
|
+
this.stderr = (this.stderr + d.toString()).slice(-20_000);
|
|
39
|
+
});
|
|
40
|
+
proc.once("exit", (code) => {
|
|
41
|
+
if (this.proc !== proc)
|
|
42
|
+
return;
|
|
43
|
+
this.proc = undefined;
|
|
44
|
+
for (const p of this.pending.values()) {
|
|
45
|
+
clearTimeout(p.timer);
|
|
46
|
+
p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
|
|
47
|
+
}
|
|
48
|
+
this.pending.clear();
|
|
49
|
+
this.onExit?.(code);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
feed(text) {
|
|
53
|
+
this.buf += text;
|
|
54
|
+
let i;
|
|
55
|
+
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
56
|
+
const line = this.buf.slice(0, i).replace(/\r$/, "");
|
|
57
|
+
this.buf = this.buf.slice(i + 1);
|
|
58
|
+
if (!line.trim())
|
|
59
|
+
continue;
|
|
60
|
+
let rec;
|
|
61
|
+
try {
|
|
62
|
+
rec = JSON.parse(line);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
|
|
68
|
+
const p = this.pending.get(rec.id);
|
|
69
|
+
this.pending.delete(rec.id);
|
|
70
|
+
clearTimeout(p.timer);
|
|
71
|
+
if (rec.success === false)
|
|
72
|
+
p.reject(new Error(rec.error ?? `${rec.command} failed`));
|
|
73
|
+
else
|
|
74
|
+
p.resolve(rec.data);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const l of this.listeners)
|
|
78
|
+
l(rec);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
on(l) {
|
|
82
|
+
this.listeners.add(l);
|
|
83
|
+
return () => this.listeners.delete(l);
|
|
84
|
+
}
|
|
85
|
+
write(rec) {
|
|
86
|
+
const stdin = this.proc?.stdin;
|
|
87
|
+
if (!stdin || !stdin.writable)
|
|
88
|
+
throw new Error("pi is not running");
|
|
89
|
+
stdin.write(`${JSON.stringify(rec)}\n`);
|
|
90
|
+
}
|
|
91
|
+
request(cmd, timeoutMs = 30_000) {
|
|
92
|
+
const id = `w${++this.n}`;
|
|
93
|
+
return new Promise((resolvePromise, reject) => {
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
this.pending.delete(id);
|
|
96
|
+
reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
this.pending.set(id, { resolve: resolvePromise, reject, timer });
|
|
99
|
+
try {
|
|
100
|
+
this.write({ ...cmd, id });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
this.pending.delete(id);
|
|
105
|
+
reject(err);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async stop() {
|
|
110
|
+
const proc = this.proc;
|
|
111
|
+
if (!proc)
|
|
112
|
+
return;
|
|
113
|
+
this.proc = undefined;
|
|
114
|
+
for (const p of this.pending.values()) {
|
|
115
|
+
clearTimeout(p.timer);
|
|
116
|
+
p.reject(new Error("pi was stopped"));
|
|
117
|
+
}
|
|
118
|
+
this.pending.clear();
|
|
119
|
+
await new Promise((done) => {
|
|
120
|
+
const t = setTimeout(() => {
|
|
121
|
+
proc.kill("SIGKILL");
|
|
122
|
+
done();
|
|
123
|
+
}, 3000);
|
|
124
|
+
proc.once("exit", () => {
|
|
125
|
+
clearTimeout(t);
|
|
126
|
+
done();
|
|
127
|
+
});
|
|
128
|
+
proc.kill();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The dialogs that wait for an answer (extension_ui_response): the rest of the extension UI requests are notices. */
|
|
133
|
+
export function isDialog(ev) {
|
|
134
|
+
return ev?.type === "extension_ui_request" && ["select", "confirm", "input", "editor"].includes(ev.method);
|
|
135
|
+
}
|
|
136
|
+
/** What the extension sends as status texts: the state of the library and the answer to the last library search. */
|
|
137
|
+
export class Side {
|
|
138
|
+
state = null;
|
|
139
|
+
find = null;
|
|
140
|
+
/** Decode an extension_ui_request; tells which of the two it was, or undefined. */
|
|
141
|
+
take(ev) {
|
|
142
|
+
if (ev?.type !== "extension_ui_request" || ev.method !== "setStatus")
|
|
143
|
+
return undefined;
|
|
144
|
+
const decode = () => {
|
|
145
|
+
try {
|
|
146
|
+
return ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
if (ev.statusKey === "subsub.web") {
|
|
153
|
+
this.state = decode();
|
|
154
|
+
return "state";
|
|
155
|
+
}
|
|
156
|
+
if (ev.statusKey === "subsub.find") {
|
|
157
|
+
this.find = decode();
|
|
158
|
+
return "find";
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Search the library through the extension (the hidden /subsub-find command); the answer comes back as a status text. */
|
|
164
|
+
export async function findLibrary(agent, side, q) {
|
|
165
|
+
if (!q)
|
|
166
|
+
return { q, items: [] };
|
|
167
|
+
await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
|
|
168
|
+
return side.find?.q === q ? side.find : { q, items: [] };
|
|
169
|
+
}
|
|
170
|
+
function readText(path) {
|
|
171
|
+
try {
|
|
172
|
+
return readFileSync(path, "utf8");
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** RPC has no argument hints: read them from the front matter of the prompt files. */
|
|
179
|
+
export function withHints(commands) {
|
|
180
|
+
return commands.map((c) => {
|
|
181
|
+
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
182
|
+
const hint = front && field(front, "argument-hint");
|
|
183
|
+
return hint ? { ...c, hint } : c;
|
|
184
|
+
});
|
|
185
|
+
}
|
package/dist/screens.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
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
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { cut, evidenceLine, fill, rule } from "./chrome.js";
|
|
8
|
+
import { frontMatter, field } from "./notes.js";
|
|
9
|
+
import { UNICODE } from "./skin.js";
|
|
10
|
+
export const SCREEN_WORDS = {
|
|
11
|
+
en: {
|
|
12
|
+
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)",
|
|
13
|
+
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",
|
|
14
|
+
library: "Library", typeToSearch: "Type to search your library.", searching: "Searching...", noItems: "Nothing found.", failed: "The search failed",
|
|
15
|
+
hintLibrary: "up and down: choose Enter: read ctrl+n: note Esc: close", hintLibrarian: "up and down: choose Enter: copy the key Esc: close",
|
|
16
|
+
hintChoice: "up and down: choose Enter: select type: filter Esc: cancel", noChoice: "Nothing matches.", hintInput: "Enter: send Esc: cancel",
|
|
17
|
+
},
|
|
18
|
+
pt: {
|
|
19
|
+
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)",
|
|
20
|
+
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",
|
|
21
|
+
library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
|
|
22
|
+
hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
|
|
23
|
+
hintChoice: "cima e baixo: escolher Enter: selecionar escrever: filtrar Esc: cancelar", noChoice: "Nada corresponde.", hintInput: "Enter: enviar Esc: cancelar",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const LEFT = 36;
|
|
27
|
+
const WIDE = 100;
|
|
28
|
+
function pad(s, width) {
|
|
29
|
+
return s + " ".repeat(Math.max(0, width - visibleWidth(s)));
|
|
30
|
+
}
|
|
31
|
+
/** A key that types text (a letter, a digit, a pasted string), not a control or escape sequence. */
|
|
32
|
+
export function printable(data) {
|
|
33
|
+
return data.length > 0 && !/[\x00-\x1f\x7f]/.test(data);
|
|
34
|
+
}
|
|
35
|
+
export function notesLines(o) {
|
|
36
|
+
const g = o.glyphs ?? UNICODE;
|
|
37
|
+
const p = o.paint;
|
|
38
|
+
const w = Math.max(20, o.width);
|
|
39
|
+
const words = SCREEN_WORDS[o.lang];
|
|
40
|
+
const rows = Math.max(3, o.rows - 5);
|
|
41
|
+
const wide = w >= WIDE && o.mode === "list";
|
|
42
|
+
const count = o.entries.length === o.total ? `${o.total}` : `${o.entries.length} ${words.of} ${o.total}`;
|
|
43
|
+
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)];
|
|
44
|
+
let bodyMax = 0;
|
|
45
|
+
const hint = (text) => wrapTextWithAnsi(` ${text}`, w).map((l) => p.muted(l));
|
|
46
|
+
if (o.total === 0 || o.entries.length === 0) {
|
|
47
|
+
out.push(p.muted(cut(` ${o.total === 0 ? words.none : words.noMatch}`, w)), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
48
|
+
return { lines: out, bodyMax };
|
|
49
|
+
}
|
|
50
|
+
const list = (width) => {
|
|
51
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(rows / 2), o.entries.length - rows));
|
|
52
|
+
return o.entries.slice(start, start + rows).map((rel, k) => {
|
|
53
|
+
const chosen = start + k === o.selected;
|
|
54
|
+
return chosen ? `${p.mark(g.pointer)} ${p.text(cut(rel, width - 2))}` : ` ${p.muted(cut(rel, width - 2))}`;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const body = (width) => {
|
|
58
|
+
const lines = o.renderBody(Math.max(10, width));
|
|
59
|
+
const head = o.evidence ? [evidenceLine(o.evidence, o.lang, width, p, g), ""] : [];
|
|
60
|
+
const room = Math.max(1, rows - head.length);
|
|
61
|
+
bodyMax = Math.max(0, lines.length - room);
|
|
62
|
+
const from = Math.min(o.bodyScroll, bodyMax);
|
|
63
|
+
const shown = lines.slice(from, from + room).map((l) => truncateToWidth(l, width));
|
|
64
|
+
return [...head, ...(shown.length ? shown : [p.dim(words.empty)])];
|
|
65
|
+
};
|
|
66
|
+
if (wide) {
|
|
67
|
+
const right = w - LEFT - 3;
|
|
68
|
+
const left = list(LEFT);
|
|
69
|
+
const detail = body(right);
|
|
70
|
+
for (let i = 0; i < rows; i++)
|
|
71
|
+
out.push(`${pad(left[i] ?? "", LEFT)} ${p.muted(g.vertical)} ${detail[i] ?? ""}`);
|
|
72
|
+
out.push(rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
73
|
+
}
|
|
74
|
+
else if (o.mode === "list") {
|
|
75
|
+
out.push(...list(w), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
out.push(...body(w), rule(w, "solid", p.muted, g), ...hint(words.hintRead));
|
|
79
|
+
}
|
|
80
|
+
return { lines: out, bodyMax };
|
|
81
|
+
}
|
|
82
|
+
export class NotesView {
|
|
83
|
+
o;
|
|
84
|
+
filter = "";
|
|
85
|
+
selected = 0;
|
|
86
|
+
mode = "list";
|
|
87
|
+
bodyScroll = 0;
|
|
88
|
+
bodyMax = 0;
|
|
89
|
+
lastWidth = 80;
|
|
90
|
+
texts = new Map();
|
|
91
|
+
constructor(o) {
|
|
92
|
+
this.o = o;
|
|
93
|
+
}
|
|
94
|
+
entries() {
|
|
95
|
+
const f = this.filter.toLowerCase();
|
|
96
|
+
return f ? this.o.notes.filter((n) => n.rel.toLowerCase().includes(f)) : this.o.notes;
|
|
97
|
+
}
|
|
98
|
+
text() {
|
|
99
|
+
const n = this.entries()[this.selected];
|
|
100
|
+
if (!n)
|
|
101
|
+
return "";
|
|
102
|
+
let t = this.texts.get(n.path);
|
|
103
|
+
if (t === undefined) {
|
|
104
|
+
try {
|
|
105
|
+
t = this.o.read(n.path);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
t = "";
|
|
109
|
+
}
|
|
110
|
+
this.texts.set(n.path, t);
|
|
111
|
+
}
|
|
112
|
+
return t;
|
|
113
|
+
}
|
|
114
|
+
render(width) {
|
|
115
|
+
this.lastWidth = width;
|
|
116
|
+
const text = this.text();
|
|
117
|
+
const front = frontMatter(text);
|
|
118
|
+
const evidence = front ? field(front, "evidence") : undefined;
|
|
119
|
+
const body = front ? text.slice(text.indexOf("\n---", 3) + 4).replace(/^\r?\n/, "") : text;
|
|
120
|
+
const entries = this.entries();
|
|
121
|
+
const view = notesLines({
|
|
122
|
+
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,
|
|
123
|
+
selected: this.selected, filter: this.filter, evidence, renderBody: (w) => this.o.markdown(body, w), bodyScroll: this.bodyScroll,
|
|
124
|
+
});
|
|
125
|
+
this.bodyMax = view.bodyMax;
|
|
126
|
+
this.bodyScroll = Math.min(this.bodyScroll, this.bodyMax);
|
|
127
|
+
return fill(view.lines, width, this.height());
|
|
128
|
+
}
|
|
129
|
+
/** The screen covers the terminal, but for one line. */
|
|
130
|
+
height() {
|
|
131
|
+
return Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
132
|
+
}
|
|
133
|
+
invalidate() { }
|
|
134
|
+
move(by) {
|
|
135
|
+
const n = this.entries().length;
|
|
136
|
+
if (!n)
|
|
137
|
+
return;
|
|
138
|
+
this.selected = Math.min(n - 1, Math.max(0, this.selected + by));
|
|
139
|
+
this.bodyScroll = 0;
|
|
140
|
+
}
|
|
141
|
+
handleInput(data) {
|
|
142
|
+
const page = Math.max(3, this.height() - 6);
|
|
143
|
+
const wideList = this.lastWidth >= WIDE && this.mode === "list";
|
|
144
|
+
const scrolls = this.mode === "read" || wideList;
|
|
145
|
+
if (matchesKey(data, "ctrl+c"))
|
|
146
|
+
return this.o.done();
|
|
147
|
+
if (matchesKey(data, "escape")) {
|
|
148
|
+
if (this.mode === "read")
|
|
149
|
+
this.mode = "list";
|
|
150
|
+
else if (this.filter) {
|
|
151
|
+
this.filter = "";
|
|
152
|
+
this.selected = 0;
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
return this.o.done();
|
|
156
|
+
}
|
|
157
|
+
else if (matchesKey(data, "ctrl+o")) {
|
|
158
|
+
const n = this.entries()[this.selected];
|
|
159
|
+
if (n)
|
|
160
|
+
this.o.open(n.path);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
else if (matchesKey(data, "enter")) {
|
|
164
|
+
if (this.mode === "list" && !wideList && this.entries().length) {
|
|
165
|
+
this.mode = "read";
|
|
166
|
+
this.bodyScroll = 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (matchesKey(data, "up")) {
|
|
170
|
+
if (this.mode === "read")
|
|
171
|
+
this.bodyScroll = Math.max(0, this.bodyScroll - 1);
|
|
172
|
+
else
|
|
173
|
+
this.move(-1);
|
|
174
|
+
}
|
|
175
|
+
else if (matchesKey(data, "down")) {
|
|
176
|
+
if (this.mode === "read")
|
|
177
|
+
this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + 1);
|
|
178
|
+
else
|
|
179
|
+
this.move(1);
|
|
180
|
+
}
|
|
181
|
+
else if (matchesKey(data, "pageUp")) {
|
|
182
|
+
if (scrolls)
|
|
183
|
+
this.bodyScroll = Math.max(0, this.bodyScroll - page);
|
|
184
|
+
else
|
|
185
|
+
this.move(-page);
|
|
186
|
+
}
|
|
187
|
+
else if (matchesKey(data, "pageDown")) {
|
|
188
|
+
if (scrolls)
|
|
189
|
+
this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + page);
|
|
190
|
+
else
|
|
191
|
+
this.move(page);
|
|
192
|
+
}
|
|
193
|
+
else if (this.mode === "list" && matchesKey(data, "backspace")) {
|
|
194
|
+
this.filter = this.filter.slice(0, -1);
|
|
195
|
+
this.selected = 0;
|
|
196
|
+
}
|
|
197
|
+
else if (this.mode === "list" && printable(data)) {
|
|
198
|
+
this.filter += data;
|
|
199
|
+
this.selected = 0;
|
|
200
|
+
this.bodyScroll = 0;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
return;
|
|
204
|
+
this.o.redraw?.();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function libraryLines(o) {
|
|
208
|
+
const g = o.glyphs ?? UNICODE;
|
|
209
|
+
const p = o.paint;
|
|
210
|
+
const w = Math.max(20, o.width);
|
|
211
|
+
const words = SCREEN_WORDS[o.lang];
|
|
212
|
+
const room = Math.max(2, o.rows - 5);
|
|
213
|
+
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)];
|
|
214
|
+
if (o.state === "idle" && !o.query)
|
|
215
|
+
out.push(p.muted(cut(` ${words.typeToSearch}`, w)));
|
|
216
|
+
else if (o.state === "searching")
|
|
217
|
+
out.push(p.muted(cut(` ${words.searching}`, w)));
|
|
218
|
+
else if (o.state === "failed")
|
|
219
|
+
out.push(p.text(cut(` ${g.hollow} ${words.failed}${o.error ? `: ${o.error.split("\n")[0]}` : ""}`, w)));
|
|
220
|
+
else if (!o.items.length)
|
|
221
|
+
out.push(p.muted(cut(` ${words.noItems}`, w)));
|
|
222
|
+
else {
|
|
223
|
+
const per = 2;
|
|
224
|
+
const fit = Math.max(1, Math.floor(room / per));
|
|
225
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
|
|
226
|
+
for (const [k, it] of o.items.slice(start, start + fit).entries()) {
|
|
227
|
+
const chosen = start + k === o.selected;
|
|
228
|
+
const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
|
|
229
|
+
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))}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${o.researcher ? words.hintLibrary : words.hintLibrarian}`, w).map((l) => p.muted(l)));
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
export class LibraryView {
|
|
236
|
+
o;
|
|
237
|
+
query = "";
|
|
238
|
+
items = [];
|
|
239
|
+
selected = 0;
|
|
240
|
+
state = "idle";
|
|
241
|
+
error;
|
|
242
|
+
timer;
|
|
243
|
+
seq = 0;
|
|
244
|
+
constructor(o) {
|
|
245
|
+
this.o = o;
|
|
246
|
+
}
|
|
247
|
+
render(width) {
|
|
248
|
+
const height = Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
249
|
+
return fill(libraryLines({
|
|
250
|
+
width, rows: height, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs, researcher: this.o.researcher,
|
|
251
|
+
query: this.query, items: this.items, selected: this.selected, state: this.state, error: this.error,
|
|
252
|
+
}), width, height);
|
|
253
|
+
}
|
|
254
|
+
invalidate() { }
|
|
255
|
+
dispose() {
|
|
256
|
+
clearTimeout(this.timer);
|
|
257
|
+
}
|
|
258
|
+
schedule() {
|
|
259
|
+
clearTimeout(this.timer);
|
|
260
|
+
const q = this.query.replace(/\s+/g, " ").trim();
|
|
261
|
+
const mine = ++this.seq;
|
|
262
|
+
if (!q) {
|
|
263
|
+
this.items = [];
|
|
264
|
+
this.state = "idle";
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.state = "searching";
|
|
268
|
+
this.timer = setTimeout(async () => {
|
|
269
|
+
try {
|
|
270
|
+
const items = await this.o.search(q);
|
|
271
|
+
if (mine !== this.seq)
|
|
272
|
+
return; // a newer search is on its way
|
|
273
|
+
this.items = items;
|
|
274
|
+
this.state = "done";
|
|
275
|
+
this.selected = 0;
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (mine !== this.seq)
|
|
279
|
+
return;
|
|
280
|
+
this.state = "failed";
|
|
281
|
+
this.error = err.message;
|
|
282
|
+
}
|
|
283
|
+
this.o.redraw?.();
|
|
284
|
+
}, this.o.delay ?? 250);
|
|
285
|
+
}
|
|
286
|
+
handleInput(data) {
|
|
287
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
|
|
288
|
+
return this.o.done();
|
|
289
|
+
const item = this.items[this.selected];
|
|
290
|
+
if (matchesKey(data, "enter")) {
|
|
291
|
+
if (item)
|
|
292
|
+
this.o.choose(item);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (matchesKey(data, "ctrl+n")) {
|
|
296
|
+
if (item && this.o.researcher)
|
|
297
|
+
this.o.note(item);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (matchesKey(data, "up"))
|
|
301
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
302
|
+
else if (matchesKey(data, "down"))
|
|
303
|
+
this.selected = Math.min(Math.max(0, this.items.length - 1), this.selected + 1);
|
|
304
|
+
else if (matchesKey(data, "backspace")) {
|
|
305
|
+
this.query = this.query.slice(0, -1);
|
|
306
|
+
this.schedule();
|
|
307
|
+
}
|
|
308
|
+
else if (printable(data)) {
|
|
309
|
+
this.query += data;
|
|
310
|
+
this.schedule();
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
return;
|
|
314
|
+
this.o.redraw?.();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function choiceLines(o) {
|
|
318
|
+
const g = o.glyphs ?? UNICODE;
|
|
319
|
+
const p = o.paint;
|
|
320
|
+
const w = Math.max(20, o.width);
|
|
321
|
+
const words = SCREEN_WORDS[o.lang];
|
|
322
|
+
const room = Math.max(1, o.rows - 4);
|
|
323
|
+
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)];
|
|
324
|
+
if (!o.choices.length)
|
|
325
|
+
out.push(p.muted(cut(` ${words.noChoice}`, w)));
|
|
326
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(room / 2), o.choices.length - room));
|
|
327
|
+
for (const [k, c] of o.choices.slice(start, start + room).entries()) {
|
|
328
|
+
const chosen = start + k === o.selected;
|
|
329
|
+
const hint = c.hint ? ` ${c.hint}` : "";
|
|
330
|
+
const label = cut(c.label, Math.max(8, w - 2 - hint.length));
|
|
331
|
+
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)))}`);
|
|
332
|
+
}
|
|
333
|
+
out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${words.hintChoice}`, w).map((l) => p.muted(l)));
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
export class ChoiceView {
|
|
337
|
+
o;
|
|
338
|
+
filter = "";
|
|
339
|
+
selected = 0;
|
|
340
|
+
answered = false;
|
|
341
|
+
constructor(o) {
|
|
342
|
+
this.o = o;
|
|
343
|
+
}
|
|
344
|
+
shown() {
|
|
345
|
+
const f = this.filter.toLowerCase();
|
|
346
|
+
return f ? this.o.choices.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(f)) : this.o.choices;
|
|
347
|
+
}
|
|
348
|
+
render(width) {
|
|
349
|
+
const rows = Math.max(8, Math.floor(((this.o.rows?.() ?? 24) * 8) / 10));
|
|
350
|
+
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);
|
|
351
|
+
}
|
|
352
|
+
invalidate() { }
|
|
353
|
+
handleInput(data) {
|
|
354
|
+
if (this.answered)
|
|
355
|
+
return;
|
|
356
|
+
const list = this.shown();
|
|
357
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
358
|
+
this.answered = true;
|
|
359
|
+
return this.o.cancel();
|
|
360
|
+
}
|
|
361
|
+
if (matchesKey(data, "enter")) {
|
|
362
|
+
const c = list[this.selected];
|
|
363
|
+
if (!c)
|
|
364
|
+
return;
|
|
365
|
+
this.answered = true;
|
|
366
|
+
return this.o.choose(c.value);
|
|
367
|
+
}
|
|
368
|
+
if (matchesKey(data, "up"))
|
|
369
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
370
|
+
else if (matchesKey(data, "down"))
|
|
371
|
+
this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
|
|
372
|
+
else if (matchesKey(data, "backspace")) {
|
|
373
|
+
this.filter = this.filter.slice(0, -1);
|
|
374
|
+
this.selected = 0;
|
|
375
|
+
}
|
|
376
|
+
else if (printable(data)) {
|
|
377
|
+
this.filter += data;
|
|
378
|
+
this.selected = 0;
|
|
379
|
+
}
|
|
380
|
+
else
|
|
381
|
+
return;
|
|
382
|
+
this.o.redraw?.();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export function inputLines(o) {
|
|
386
|
+
const g = o.glyphs ?? UNICODE;
|
|
387
|
+
const p = o.paint;
|
|
388
|
+
const w = Math.max(20, o.width);
|
|
389
|
+
const shown = o.text ? p.text(cut(o.text, w - 5)) + p.mark("_") : p.dim(cut(o.placeholder ?? "", w - 5)) + p.mark("_");
|
|
390
|
+
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))];
|
|
391
|
+
}
|
|
392
|
+
export class InputView {
|
|
393
|
+
o;
|
|
394
|
+
text;
|
|
395
|
+
answered = false;
|
|
396
|
+
constructor(o) {
|
|
397
|
+
this.o = o;
|
|
398
|
+
this.text = o.prefill ?? "";
|
|
399
|
+
}
|
|
400
|
+
render(width) {
|
|
401
|
+
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);
|
|
402
|
+
}
|
|
403
|
+
invalidate() { }
|
|
404
|
+
handleInput(data) {
|
|
405
|
+
if (this.answered)
|
|
406
|
+
return;
|
|
407
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
408
|
+
this.answered = true;
|
|
409
|
+
return this.o.cancel();
|
|
410
|
+
}
|
|
411
|
+
if (matchesKey(data, "enter")) {
|
|
412
|
+
this.answered = true;
|
|
413
|
+
return this.o.submit(this.text);
|
|
414
|
+
}
|
|
415
|
+
if (matchesKey(data, "backspace"))
|
|
416
|
+
this.text = this.text.slice(0, -1);
|
|
417
|
+
else if (matchesKey(data, "ctrl+u"))
|
|
418
|
+
this.text = "";
|
|
419
|
+
else if (printable(data))
|
|
420
|
+
this.text += data;
|
|
421
|
+
else
|
|
422
|
+
return;
|
|
423
|
+
this.o.redraw?.();
|
|
424
|
+
}
|
|
425
|
+
}
|
package/dist/skin.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export const UNICODE = { solid: "─", dashed: "┄", dotted: "┈", hollow: "○", cell: "▰", cellEmpty: "▱", vertical: "│", pointer: "›" };
|
|
7
|
+
export const ASCII = { solid: "=", dashed: "-", dotted: ".", hollow: "o", cell: "#", cellEmpty: "-", vertical: "|", pointer: ">" };
|
|
8
|
+
/** 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. */
|
|
9
|
+
export function useAscii(env = process.env) {
|
|
10
|
+
if (env.SUBSUB_ASCII && env.SUBSUB_ASCII !== "0")
|
|
11
|
+
return true;
|
|
12
|
+
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
|
|
13
|
+
if (!locale || /utf-?8/i.test(locale))
|
|
14
|
+
return false;
|
|
15
|
+
return locale === "C" || locale === "POSIX" || /\.[^.]+$/.test(locale);
|
|
16
|
+
}
|
|
17
|
+
export function glyphs(env = process.env) {
|
|
18
|
+
return useAscii(env) ? ASCII : UNICODE;
|
|
19
|
+
}
|
|
20
|
+
/** Paint roles from the colours of a pi theme (themes/*.json): accent, text, muted, dim, success (sap) and warning (gamboge). */
|
|
21
|
+
export function paintFromTheme(th) {
|
|
22
|
+
return {
|
|
23
|
+
mark: (x) => th.fg("accent", x),
|
|
24
|
+
text: (x) => th.fg("text", x),
|
|
25
|
+
muted: (x) => th.fg("muted", x),
|
|
26
|
+
dim: (x) => th.fg("dim", x),
|
|
27
|
+
ok: (x) => th.fg("success", x),
|
|
28
|
+
pend: (x) => th.fg("warning", x),
|
|
29
|
+
// A removal has no colour of its own: muted ink, struck through, and the "-" in front stays for terminals that ignore the strike.
|
|
30
|
+
strike: (x) => (th.strikethrough ? th.strikethrough(th.fg("muted", x)) : th.fg("muted", x)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Frames of the working indicator: dotted, dashed, solid, dashed. One static frame when motion is reduced (SUBSUB_REDUCE_MOTION=1). */
|
|
34
|
+
export function workingFrames(g, reduceMotion = false) {
|
|
35
|
+
const dashed = g.dashed.repeat(4);
|
|
36
|
+
return reduceMotion ? [dashed] : [g.dotted.repeat(4), dashed, g.solid.repeat(4), dashed];
|
|
37
|
+
}
|