@tiagojct/subsub 0.20.0 → 0.21.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.
@@ -0,0 +1,314 @@
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
+ },
17
+ pt: {
18
+ 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)",
19
+ 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",
20
+ library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
21
+ hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
22
+ },
23
+ };
24
+ const LEFT = 36;
25
+ const WIDE = 100;
26
+ function pad(s, width) {
27
+ return s + " ".repeat(Math.max(0, width - visibleWidth(s)));
28
+ }
29
+ /** A key that types text (a letter, a digit, a pasted string), not a control or escape sequence. */
30
+ export function printable(data) {
31
+ return data.length > 0 && !/[\x00-\x1f\x7f]/.test(data);
32
+ }
33
+ export function notesLines(o) {
34
+ const g = o.glyphs ?? UNICODE;
35
+ const p = o.paint;
36
+ const w = Math.max(20, o.width);
37
+ const words = SCREEN_WORDS[o.lang];
38
+ const rows = Math.max(3, o.rows - 5);
39
+ const wide = w >= WIDE && o.mode === "list";
40
+ const count = o.entries.length === o.total ? `${o.total}` : `${o.entries.length} ${words.of} ${o.total}`;
41
+ 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)];
42
+ let bodyMax = 0;
43
+ const hint = (text) => wrapTextWithAnsi(` ${text}`, w).map((l) => p.muted(l));
44
+ if (o.total === 0 || o.entries.length === 0) {
45
+ out.push(p.muted(cut(` ${o.total === 0 ? words.none : words.noMatch}`, w)), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
46
+ return { lines: out, bodyMax };
47
+ }
48
+ const list = (width) => {
49
+ const start = Math.max(0, Math.min(o.selected - Math.floor(rows / 2), o.entries.length - rows));
50
+ return o.entries.slice(start, start + rows).map((rel, k) => {
51
+ const chosen = start + k === o.selected;
52
+ return chosen ? `${p.mark(g.pointer)} ${p.text(cut(rel, width - 2))}` : ` ${p.muted(cut(rel, width - 2))}`;
53
+ });
54
+ };
55
+ const body = (width) => {
56
+ const lines = o.renderBody(Math.max(10, width));
57
+ const head = o.evidence ? [evidenceLine(o.evidence, o.lang, width, p, g), ""] : [];
58
+ const room = Math.max(1, rows - head.length);
59
+ bodyMax = Math.max(0, lines.length - room);
60
+ const from = Math.min(o.bodyScroll, bodyMax);
61
+ const shown = lines.slice(from, from + room).map((l) => truncateToWidth(l, width));
62
+ return [...head, ...(shown.length ? shown : [p.dim(words.empty)])];
63
+ };
64
+ if (wide) {
65
+ const right = w - LEFT - 3;
66
+ const left = list(LEFT);
67
+ const detail = body(right);
68
+ for (let i = 0; i < rows; i++)
69
+ out.push(`${pad(left[i] ?? "", LEFT)} ${p.muted(g.vertical)} ${detail[i] ?? ""}`);
70
+ out.push(rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
71
+ }
72
+ else if (o.mode === "list") {
73
+ out.push(...list(w), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
74
+ }
75
+ else {
76
+ out.push(...body(w), rule(w, "solid", p.muted, g), ...hint(words.hintRead));
77
+ }
78
+ return { lines: out, bodyMax };
79
+ }
80
+ export class NotesView {
81
+ o;
82
+ filter = "";
83
+ selected = 0;
84
+ mode = "list";
85
+ bodyScroll = 0;
86
+ bodyMax = 0;
87
+ lastWidth = 80;
88
+ texts = new Map();
89
+ constructor(o) {
90
+ this.o = o;
91
+ }
92
+ entries() {
93
+ const f = this.filter.toLowerCase();
94
+ return f ? this.o.notes.filter((n) => n.rel.toLowerCase().includes(f)) : this.o.notes;
95
+ }
96
+ text() {
97
+ const n = this.entries()[this.selected];
98
+ if (!n)
99
+ return "";
100
+ let t = this.texts.get(n.path);
101
+ if (t === undefined) {
102
+ try {
103
+ t = this.o.read(n.path);
104
+ }
105
+ catch {
106
+ t = "";
107
+ }
108
+ this.texts.set(n.path, t);
109
+ }
110
+ return t;
111
+ }
112
+ render(width) {
113
+ this.lastWidth = width;
114
+ const text = this.text();
115
+ const front = frontMatter(text);
116
+ const evidence = front ? field(front, "evidence") : undefined;
117
+ const body = front ? text.slice(text.indexOf("\n---", 3) + 4).replace(/^\r?\n/, "") : text;
118
+ const entries = this.entries();
119
+ const view = notesLines({
120
+ 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,
121
+ selected: this.selected, filter: this.filter, evidence, renderBody: (w) => this.o.markdown(body, w), bodyScroll: this.bodyScroll,
122
+ });
123
+ this.bodyMax = view.bodyMax;
124
+ this.bodyScroll = Math.min(this.bodyScroll, this.bodyMax);
125
+ return fill(view.lines, width, this.height());
126
+ }
127
+ /** The screen covers the terminal, but for one line. */
128
+ height() {
129
+ return Math.max(8, (this.o.rows?.() ?? 24) - 1);
130
+ }
131
+ invalidate() { }
132
+ move(by) {
133
+ const n = this.entries().length;
134
+ if (!n)
135
+ return;
136
+ this.selected = Math.min(n - 1, Math.max(0, this.selected + by));
137
+ this.bodyScroll = 0;
138
+ }
139
+ handleInput(data) {
140
+ const page = Math.max(3, this.height() - 6);
141
+ const wideList = this.lastWidth >= WIDE && this.mode === "list";
142
+ const scrolls = this.mode === "read" || wideList;
143
+ if (matchesKey(data, "ctrl+c"))
144
+ return this.o.done();
145
+ if (matchesKey(data, "escape")) {
146
+ if (this.mode === "read")
147
+ this.mode = "list";
148
+ else if (this.filter) {
149
+ this.filter = "";
150
+ this.selected = 0;
151
+ }
152
+ else
153
+ return this.o.done();
154
+ }
155
+ else if (matchesKey(data, "ctrl+o")) {
156
+ const n = this.entries()[this.selected];
157
+ if (n)
158
+ this.o.open(n.path);
159
+ return;
160
+ }
161
+ else if (matchesKey(data, "enter")) {
162
+ if (this.mode === "list" && !wideList && this.entries().length) {
163
+ this.mode = "read";
164
+ this.bodyScroll = 0;
165
+ }
166
+ }
167
+ else if (matchesKey(data, "up")) {
168
+ if (this.mode === "read")
169
+ this.bodyScroll = Math.max(0, this.bodyScroll - 1);
170
+ else
171
+ this.move(-1);
172
+ }
173
+ else if (matchesKey(data, "down")) {
174
+ if (this.mode === "read")
175
+ this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + 1);
176
+ else
177
+ this.move(1);
178
+ }
179
+ else if (matchesKey(data, "pageUp")) {
180
+ if (scrolls)
181
+ this.bodyScroll = Math.max(0, this.bodyScroll - page);
182
+ else
183
+ this.move(-page);
184
+ }
185
+ else if (matchesKey(data, "pageDown")) {
186
+ if (scrolls)
187
+ this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + page);
188
+ else
189
+ this.move(page);
190
+ }
191
+ else if (this.mode === "list" && matchesKey(data, "backspace")) {
192
+ this.filter = this.filter.slice(0, -1);
193
+ this.selected = 0;
194
+ }
195
+ else if (this.mode === "list" && printable(data)) {
196
+ this.filter += data;
197
+ this.selected = 0;
198
+ this.bodyScroll = 0;
199
+ }
200
+ else
201
+ return;
202
+ this.o.redraw?.();
203
+ }
204
+ }
205
+ export function libraryLines(o) {
206
+ const g = o.glyphs ?? UNICODE;
207
+ const p = o.paint;
208
+ const w = Math.max(20, o.width);
209
+ const words = SCREEN_WORDS[o.lang];
210
+ const room = Math.max(2, o.rows - 5);
211
+ 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)];
212
+ if (o.state === "idle" && !o.query)
213
+ out.push(p.muted(cut(` ${words.typeToSearch}`, w)));
214
+ else if (o.state === "searching")
215
+ out.push(p.muted(cut(` ${words.searching}`, w)));
216
+ else if (o.state === "failed")
217
+ out.push(p.text(cut(` ${g.hollow} ${words.failed}${o.error ? `: ${o.error.split("\n")[0]}` : ""}`, w)));
218
+ else if (!o.items.length)
219
+ out.push(p.muted(cut(` ${words.noItems}`, w)));
220
+ else {
221
+ const per = 2;
222
+ const fit = Math.max(1, Math.floor(room / per));
223
+ const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
224
+ for (const [k, it] of o.items.slice(start, start + fit).entries()) {
225
+ const chosen = start + k === o.selected;
226
+ const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
227
+ 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))}`);
228
+ }
229
+ }
230
+ out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${o.researcher ? words.hintLibrary : words.hintLibrarian}`, w).map((l) => p.muted(l)));
231
+ return out;
232
+ }
233
+ export class LibraryView {
234
+ o;
235
+ query = "";
236
+ items = [];
237
+ selected = 0;
238
+ state = "idle";
239
+ error;
240
+ timer;
241
+ seq = 0;
242
+ constructor(o) {
243
+ this.o = o;
244
+ }
245
+ render(width) {
246
+ const height = Math.max(8, (this.o.rows?.() ?? 24) - 1);
247
+ return fill(libraryLines({
248
+ width, rows: height, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs, researcher: this.o.researcher,
249
+ query: this.query, items: this.items, selected: this.selected, state: this.state, error: this.error,
250
+ }), width, height);
251
+ }
252
+ invalidate() { }
253
+ dispose() {
254
+ clearTimeout(this.timer);
255
+ }
256
+ schedule() {
257
+ clearTimeout(this.timer);
258
+ const q = this.query.replace(/\s+/g, " ").trim();
259
+ const mine = ++this.seq;
260
+ if (!q) {
261
+ this.items = [];
262
+ this.state = "idle";
263
+ return;
264
+ }
265
+ this.state = "searching";
266
+ this.timer = setTimeout(async () => {
267
+ try {
268
+ const items = await this.o.search(q);
269
+ if (mine !== this.seq)
270
+ return; // a newer search is on its way
271
+ this.items = items;
272
+ this.state = "done";
273
+ this.selected = 0;
274
+ }
275
+ catch (err) {
276
+ if (mine !== this.seq)
277
+ return;
278
+ this.state = "failed";
279
+ this.error = err.message;
280
+ }
281
+ this.o.redraw?.();
282
+ }, this.o.delay ?? 250);
283
+ }
284
+ handleInput(data) {
285
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
286
+ return this.o.done();
287
+ const item = this.items[this.selected];
288
+ if (matchesKey(data, "enter")) {
289
+ if (item)
290
+ this.o.choose(item);
291
+ return;
292
+ }
293
+ if (matchesKey(data, "ctrl+n")) {
294
+ if (item && this.o.researcher)
295
+ this.o.note(item);
296
+ return;
297
+ }
298
+ if (matchesKey(data, "up"))
299
+ this.selected = Math.max(0, this.selected - 1);
300
+ else if (matchesKey(data, "down"))
301
+ this.selected = Math.min(Math.max(0, this.items.length - 1), this.selected + 1);
302
+ else if (matchesKey(data, "backspace")) {
303
+ this.query = this.query.slice(0, -1);
304
+ this.schedule();
305
+ }
306
+ else if (printable(data)) {
307
+ this.query += data;
308
+ this.schedule();
309
+ }
310
+ else
311
+ return;
312
+ this.o.redraw?.();
313
+ }
314
+ }
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
+ }