@tiagojct/subsub 0.19.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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -1
- package/dist/bench.js +31 -0
- package/dist/chrome.js +171 -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/screens.js +314 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +189 -29
- package/dist/web.js +7 -58
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/lit.md +1 -1
- package/prompts/verify.md +2 -2
- package/roles/researcher.md +4 -2
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/bench.ts +40 -0
- package/src/chrome.ts +208 -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/screens.ts +374 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +206 -34
- package/src/web.ts +6 -51
- 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/look.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
9
|
+
|
|
8
10
|
export const BANNER = [
|
|
9
11
|
"╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
|
|
10
12
|
"╚═╗║ ║╠╩╗───╚═╗║ ║╠╩╗",
|
|
11
13
|
"╚═╝╚═╝╚═╝ ╚═╝╚═╝╚═╝",
|
|
12
14
|
];
|
|
13
15
|
|
|
16
|
+
/** The banner for terminals without box-drawing glyphs (SUBSUB_ASCII=1 or a non-UTF-8 locale). */
|
|
17
|
+
export const BANNER_ASCII = ["+-----------+", "| Sub-Sub |", "+-----------+"];
|
|
18
|
+
|
|
14
19
|
/** Short public-domain lines from Moby-Dick and its Extracts. */
|
|
15
20
|
export const QUOTES: Array<[string, string]> = [
|
|
16
21
|
["And God created great whales.", "Genesis, in the Extracts"],
|
|
@@ -26,13 +31,16 @@ export const QUOTES: Array<[string, string]> = [
|
|
|
26
31
|
|
|
27
32
|
/** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
|
|
28
33
|
export const DEFAULT_THEME = "subsub-glauca";
|
|
34
|
+
/** The theme of the Librarian: the same plate with madder as its accent. */
|
|
35
|
+
export const LIBRARIAN_THEME = "subsub-try-works";
|
|
29
36
|
|
|
30
37
|
export type Scheme = "dark" | "light";
|
|
31
38
|
|
|
32
39
|
/** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
|
|
33
40
|
export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
34
|
-
|
|
35
|
-
if (
|
|
41
|
+
const base = themeName?.replace(/-256$/, "");
|
|
42
|
+
if (base?.endsWith("-light") || base === "light") return "light";
|
|
43
|
+
if (base?.endsWith("-dark") || base === "dark") return "dark";
|
|
36
44
|
const rgb = fgAnsi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
37
45
|
if (rgb) {
|
|
38
46
|
const [r, g, b] = rgb.slice(1).map(Number);
|
|
@@ -47,8 +55,9 @@ export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
|
47
55
|
return "dark";
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
/** The 256-colour variant ("-256") when the terminal has no truecolor: its colours are picked from the xterm palette, not rounded to it. */
|
|
59
|
+
export function themeName(scheme: Scheme, base: string | undefined = DEFAULT_THEME, colorMode?: "truecolor" | "256color"): string | undefined {
|
|
60
|
+
return base ? `${base}-${scheme}${colorMode === "256color" ? "-256" : ""}` : undefined;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export interface LibraryState {
|
|
@@ -58,26 +67,39 @@ export interface LibraryState {
|
|
|
58
67
|
error?: string;
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/** Paint roles. mark is the accent of the mode; ok (sap green) is applied or added, pend (gamboge) is pending or a preview; strike draws a removal. */
|
|
61
71
|
export interface Paint {
|
|
62
72
|
mark(s: string): string;
|
|
63
73
|
text(s: string): string;
|
|
64
74
|
muted(s: string): string;
|
|
65
75
|
dim(s: string): string;
|
|
66
|
-
|
|
76
|
+
ok(s: string): string;
|
|
77
|
+
pend(s: string): string;
|
|
78
|
+
strike(s: string): string;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const plain: Paint = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s,
|
|
81
|
+
export const plain: Paint = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, ok: (s) => s, pend: (s) => s, strike: (s) => s };
|
|
82
|
+
|
|
83
|
+
type Lang = "en" | "pt";
|
|
84
|
+
|
|
85
|
+
/** The words of the library line, as in the web view (web/i18n.js: zoteroChecking, zoteroDown, items, toReview). */
|
|
86
|
+
export const LIBRARY_WORDS = {
|
|
87
|
+
en: { checking: "Zotero: checking...", down: "Zotero is not running. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
|
|
88
|
+
pt: { checking: "Zotero: a verificar...", down: "O Zotero não está aberto. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
|
|
89
|
+
};
|
|
70
90
|
|
|
71
91
|
function cut(s: string, width: number): string {
|
|
72
92
|
return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (st.
|
|
80
|
-
|
|
95
|
+
/** The library line. A closed Zotero is not drawn in a warning colour: a hollow mark and the words say it. */
|
|
96
|
+
export function libraryLine(st: LibraryState, lang: Lang = "en", g: Glyphs = UNICODE): { text: string } {
|
|
97
|
+
const w = LIBRARY_WORDS[lang];
|
|
98
|
+
if (st.zotero === "checking") return { text: w.checking };
|
|
99
|
+
if (st.zotero === "down") return { text: `${g.hollow} ${w.down}` };
|
|
100
|
+
const parts = [`${st.items ?? "?"} ${w.items}`];
|
|
101
|
+
if (st.toReview) parts.push(`${st.toReview} ${w.toCheck}`);
|
|
102
|
+
return { text: `${w.prefix}: ${parts.join(", ")}` };
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
@@ -89,23 +111,28 @@ export function headerLines(opts: {
|
|
|
89
111
|
quote?: [string, string];
|
|
90
112
|
width: number;
|
|
91
113
|
paint?: Paint;
|
|
114
|
+
lang?: Lang;
|
|
115
|
+
glyphs?: Glyphs;
|
|
92
116
|
}): string[] {
|
|
93
117
|
const p = opts.paint ?? plain;
|
|
118
|
+
const banner = opts.glyphs && opts.glyphs !== UNICODE ? BANNER_ASCII : BANNER;
|
|
94
119
|
const w = Math.max(10, opts.width);
|
|
95
120
|
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
96
|
-
const lib = libraryLine(opts.library);
|
|
97
|
-
if (w <
|
|
98
|
-
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)),
|
|
121
|
+
const lib = libraryLine(opts.library, opts.lang, opts.glyphs);
|
|
122
|
+
if (w < banner[0].length + 2) {
|
|
123
|
+
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), p.muted(cut(lib.text, w))];
|
|
99
124
|
}
|
|
100
|
-
const out = ["", ...
|
|
125
|
+
const out = ["", ...banner.map((l, i) => p.mark(l) + (i === 2 ? p.dim(cut(` ${opts.version}`, w - l.length)) : "")), ""];
|
|
101
126
|
const status = `${modeLabel} ${opts.model}`;
|
|
102
|
-
|
|
127
|
+
// The library line follows the mode and model on the same line when it fits, otherwise on a line of its own.
|
|
128
|
+
if ([...status].length + 5 + [...lib.text].length <= w) out.push(p.text(status) + p.muted(` | ${lib.text}`));
|
|
129
|
+
else out.push(p.text(cut(status, w)), p.muted(cut(lib.text, w)));
|
|
103
130
|
if (opts.quote) {
|
|
104
131
|
const one = `"${opts.quote[0]}" (${opts.quote[1]})`;
|
|
105
132
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
106
133
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
107
134
|
}
|
|
108
|
-
out.push(p.dim(cut("/researcher /librarian /read /
|
|
135
|
+
out.push(p.dim(cut("/researcher /librarian /read /notes /library /actions | / for all commands", w)), "");
|
|
109
136
|
return out;
|
|
110
137
|
}
|
|
111
138
|
|
package/src/screens.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
},
|
|
22
|
+
pt: {
|
|
23
|
+
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)",
|
|
24
|
+
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",
|
|
25
|
+
library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
|
|
26
|
+
hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const LEFT = 36;
|
|
31
|
+
const WIDE = 100;
|
|
32
|
+
|
|
33
|
+
function pad(s: string, width: number): string {
|
|
34
|
+
return s + " ".repeat(Math.max(0, width - visibleWidth(s)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A key that types text (a letter, a digit, a pasted string), not a control or escape sequence. */
|
|
38
|
+
export function printable(data: string): boolean {
|
|
39
|
+
return data.length > 0 && !/[\x00-\x1f\x7f]/.test(data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- notes
|
|
43
|
+
|
|
44
|
+
export interface NotesLayout {
|
|
45
|
+
width: number;
|
|
46
|
+
/** Lines for the whole screen. */
|
|
47
|
+
rows: number;
|
|
48
|
+
lang: Lang;
|
|
49
|
+
paint: Paint;
|
|
50
|
+
glyphs?: Glyphs;
|
|
51
|
+
mode: "list" | "read";
|
|
52
|
+
/** Paths of the notes that match the filter, relative to the Sub-Sub folder. */
|
|
53
|
+
entries: string[];
|
|
54
|
+
total: number;
|
|
55
|
+
selected: number;
|
|
56
|
+
filter: string;
|
|
57
|
+
evidence?: string;
|
|
58
|
+
/** The chosen note as lines, for a width. */
|
|
59
|
+
renderBody: (width: number) => string[];
|
|
60
|
+
bodyScroll: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function notesLines(o: NotesLayout): { lines: string[]; bodyMax: number } {
|
|
64
|
+
const g = o.glyphs ?? UNICODE;
|
|
65
|
+
const p = o.paint;
|
|
66
|
+
const w = Math.max(20, o.width);
|
|
67
|
+
const words = SCREEN_WORDS[o.lang];
|
|
68
|
+
const rows = Math.max(3, o.rows - 5);
|
|
69
|
+
const wide = w >= WIDE && o.mode === "list";
|
|
70
|
+
const count = o.entries.length === o.total ? `${o.total}` : `${o.entries.length} ${words.of} ${o.total}`;
|
|
71
|
+
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)];
|
|
72
|
+
let bodyMax = 0;
|
|
73
|
+
const hint = (text: string) => wrapTextWithAnsi(` ${text}`, w).map((l) => p.muted(l));
|
|
74
|
+
if (o.total === 0 || o.entries.length === 0) {
|
|
75
|
+
out.push(p.muted(cut(` ${o.total === 0 ? words.none : words.noMatch}`, w)), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
76
|
+
return { lines: out, bodyMax };
|
|
77
|
+
}
|
|
78
|
+
const list = (width: number): string[] => {
|
|
79
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(rows / 2), o.entries.length - rows));
|
|
80
|
+
return o.entries.slice(start, start + rows).map((rel, k) => {
|
|
81
|
+
const chosen = start + k === o.selected;
|
|
82
|
+
return chosen ? `${p.mark(g.pointer)} ${p.text(cut(rel, width - 2))}` : ` ${p.muted(cut(rel, width - 2))}`;
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const body = (width: number): string[] => {
|
|
86
|
+
const lines = o.renderBody(Math.max(10, width));
|
|
87
|
+
const head = o.evidence ? [evidenceLine(o.evidence, o.lang, width, p, g), ""] : [];
|
|
88
|
+
const room = Math.max(1, rows - head.length);
|
|
89
|
+
bodyMax = Math.max(0, lines.length - room);
|
|
90
|
+
const from = Math.min(o.bodyScroll, bodyMax);
|
|
91
|
+
const shown = lines.slice(from, from + room).map((l) => truncateToWidth(l, width));
|
|
92
|
+
return [...head, ...(shown.length ? shown : [p.dim(words.empty)])];
|
|
93
|
+
};
|
|
94
|
+
if (wide) {
|
|
95
|
+
const right = w - LEFT - 3;
|
|
96
|
+
const left = list(LEFT);
|
|
97
|
+
const detail = body(right);
|
|
98
|
+
for (let i = 0; i < rows; i++) out.push(`${pad(left[i] ?? "", LEFT)} ${p.muted(g.vertical)} ${detail[i] ?? ""}`);
|
|
99
|
+
out.push(rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
100
|
+
} else if (o.mode === "list") {
|
|
101
|
+
out.push(...list(w), rule(w, "solid", p.muted, g), ...hint(words.hintNotes));
|
|
102
|
+
} else {
|
|
103
|
+
out.push(...body(w), rule(w, "solid", p.muted, g), ...hint(words.hintRead));
|
|
104
|
+
}
|
|
105
|
+
return { lines: out, bodyMax };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface NotesOptions {
|
|
109
|
+
/** The notes, newest first. */
|
|
110
|
+
notes: Array<{ path: string; rel: string }>;
|
|
111
|
+
read: (path: string) => string;
|
|
112
|
+
/** The text of a note as lines, for a width (pi's Markdown component in the terminal). */
|
|
113
|
+
markdown: (text: string, width: number) => string[];
|
|
114
|
+
open: (path: string) => void;
|
|
115
|
+
done: () => void;
|
|
116
|
+
lang: Lang;
|
|
117
|
+
paint: Paint;
|
|
118
|
+
glyphs?: Glyphs;
|
|
119
|
+
rows?: () => number;
|
|
120
|
+
redraw?: () => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class NotesView {
|
|
124
|
+
private readonly o: NotesOptions;
|
|
125
|
+
private filter = "";
|
|
126
|
+
private selected = 0;
|
|
127
|
+
private mode: "list" | "read" = "list";
|
|
128
|
+
private bodyScroll = 0;
|
|
129
|
+
private bodyMax = 0;
|
|
130
|
+
private lastWidth = 80;
|
|
131
|
+
private readonly texts = new Map<string, string>();
|
|
132
|
+
constructor(o: NotesOptions) {
|
|
133
|
+
this.o = o;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private entries() {
|
|
137
|
+
const f = this.filter.toLowerCase();
|
|
138
|
+
return f ? this.o.notes.filter((n) => n.rel.toLowerCase().includes(f)) : this.o.notes;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private text(): string {
|
|
142
|
+
const n = this.entries()[this.selected];
|
|
143
|
+
if (!n) return "";
|
|
144
|
+
let t = this.texts.get(n.path);
|
|
145
|
+
if (t === undefined) {
|
|
146
|
+
try {
|
|
147
|
+
t = this.o.read(n.path);
|
|
148
|
+
} catch {
|
|
149
|
+
t = "";
|
|
150
|
+
}
|
|
151
|
+
this.texts.set(n.path, t);
|
|
152
|
+
}
|
|
153
|
+
return t;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
render(width: number): string[] {
|
|
157
|
+
this.lastWidth = width;
|
|
158
|
+
const text = this.text();
|
|
159
|
+
const front = frontMatter(text);
|
|
160
|
+
const evidence = front ? field(front, "evidence") : undefined;
|
|
161
|
+
const body = front ? text.slice(text.indexOf("\n---", 3) + 4).replace(/^\r?\n/, "") : text;
|
|
162
|
+
const entries = this.entries();
|
|
163
|
+
const view = notesLines({
|
|
164
|
+
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,
|
|
165
|
+
selected: this.selected, filter: this.filter, evidence, renderBody: (w) => this.o.markdown(body, w), bodyScroll: this.bodyScroll,
|
|
166
|
+
});
|
|
167
|
+
this.bodyMax = view.bodyMax;
|
|
168
|
+
this.bodyScroll = Math.min(this.bodyScroll, this.bodyMax);
|
|
169
|
+
return fill(view.lines, width, this.height());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The screen covers the terminal, but for one line. */
|
|
173
|
+
private height(): number {
|
|
174
|
+
return Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
invalidate(): void {}
|
|
178
|
+
|
|
179
|
+
private move(by: number): void {
|
|
180
|
+
const n = this.entries().length;
|
|
181
|
+
if (!n) return;
|
|
182
|
+
this.selected = Math.min(n - 1, Math.max(0, this.selected + by));
|
|
183
|
+
this.bodyScroll = 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
handleInput(data: string): void {
|
|
187
|
+
const page = Math.max(3, this.height() - 6);
|
|
188
|
+
const wideList = this.lastWidth >= WIDE && this.mode === "list";
|
|
189
|
+
const scrolls = this.mode === "read" || wideList;
|
|
190
|
+
if (matchesKey(data, "ctrl+c")) return this.o.done();
|
|
191
|
+
if (matchesKey(data, "escape")) {
|
|
192
|
+
if (this.mode === "read") this.mode = "list";
|
|
193
|
+
else if (this.filter) {
|
|
194
|
+
this.filter = "";
|
|
195
|
+
this.selected = 0;
|
|
196
|
+
} else return this.o.done();
|
|
197
|
+
} else if (matchesKey(data, "ctrl+o")) {
|
|
198
|
+
const n = this.entries()[this.selected];
|
|
199
|
+
if (n) this.o.open(n.path);
|
|
200
|
+
return;
|
|
201
|
+
} else if (matchesKey(data, "enter")) {
|
|
202
|
+
if (this.mode === "list" && !wideList && this.entries().length) {
|
|
203
|
+
this.mode = "read";
|
|
204
|
+
this.bodyScroll = 0;
|
|
205
|
+
}
|
|
206
|
+
} else if (matchesKey(data, "up")) {
|
|
207
|
+
if (this.mode === "read") this.bodyScroll = Math.max(0, this.bodyScroll - 1);
|
|
208
|
+
else this.move(-1);
|
|
209
|
+
} else if (matchesKey(data, "down")) {
|
|
210
|
+
if (this.mode === "read") this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + 1);
|
|
211
|
+
else this.move(1);
|
|
212
|
+
} else if (matchesKey(data, "pageUp")) {
|
|
213
|
+
if (scrolls) this.bodyScroll = Math.max(0, this.bodyScroll - page);
|
|
214
|
+
else this.move(-page);
|
|
215
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
216
|
+
if (scrolls) this.bodyScroll = Math.min(this.bodyMax, this.bodyScroll + page);
|
|
217
|
+
else this.move(page);
|
|
218
|
+
} else if (this.mode === "list" && matchesKey(data, "backspace")) {
|
|
219
|
+
this.filter = this.filter.slice(0, -1);
|
|
220
|
+
this.selected = 0;
|
|
221
|
+
} else if (this.mode === "list" && printable(data)) {
|
|
222
|
+
this.filter += data;
|
|
223
|
+
this.selected = 0;
|
|
224
|
+
this.bodyScroll = 0;
|
|
225
|
+
} else return;
|
|
226
|
+
this.o.redraw?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------- library
|
|
231
|
+
|
|
232
|
+
export interface LibraryItem {
|
|
233
|
+
key: string;
|
|
234
|
+
citekey?: string;
|
|
235
|
+
title?: string;
|
|
236
|
+
authors?: string;
|
|
237
|
+
year?: string | number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface LibraryLayout {
|
|
241
|
+
width: number;
|
|
242
|
+
rows: number;
|
|
243
|
+
lang: Lang;
|
|
244
|
+
paint: Paint;
|
|
245
|
+
glyphs?: Glyphs;
|
|
246
|
+
researcher: boolean;
|
|
247
|
+
query: string;
|
|
248
|
+
items: LibraryItem[];
|
|
249
|
+
selected: number;
|
|
250
|
+
state: "idle" | "searching" | "done" | "failed";
|
|
251
|
+
error?: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function libraryLines(o: LibraryLayout): string[] {
|
|
255
|
+
const g = o.glyphs ?? UNICODE;
|
|
256
|
+
const p = o.paint;
|
|
257
|
+
const w = Math.max(20, o.width);
|
|
258
|
+
const words = SCREEN_WORDS[o.lang];
|
|
259
|
+
const room = Math.max(2, o.rows - 5);
|
|
260
|
+
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)];
|
|
261
|
+
if (o.state === "idle" && !o.query) out.push(p.muted(cut(` ${words.typeToSearch}`, w)));
|
|
262
|
+
else if (o.state === "searching") out.push(p.muted(cut(` ${words.searching}`, w)));
|
|
263
|
+
else if (o.state === "failed") out.push(p.text(cut(` ${g.hollow} ${words.failed}${o.error ? `: ${o.error.split("\n")[0]}` : ""}`, w)));
|
|
264
|
+
else if (!o.items.length) out.push(p.muted(cut(` ${words.noItems}`, w)));
|
|
265
|
+
else {
|
|
266
|
+
const per = 2;
|
|
267
|
+
const fit = Math.max(1, Math.floor(room / per));
|
|
268
|
+
const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
|
|
269
|
+
for (const [k, it] of o.items.slice(start, start + fit).entries()) {
|
|
270
|
+
const chosen = start + k === o.selected;
|
|
271
|
+
const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
|
|
272
|
+
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))}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${o.researcher ? words.hintLibrary : words.hintLibrarian}`, w).map((l) => p.muted(l)));
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface LibraryOptions {
|
|
280
|
+
search: (query: string) => Promise<LibraryItem[]>;
|
|
281
|
+
/** Enter: the researcher reads the item, the librarian gets its key. */
|
|
282
|
+
choose: (item: LibraryItem) => void;
|
|
283
|
+
note: (item: LibraryItem) => void;
|
|
284
|
+
done: () => void;
|
|
285
|
+
lang: Lang;
|
|
286
|
+
paint: Paint;
|
|
287
|
+
glyphs?: Glyphs;
|
|
288
|
+
researcher: boolean;
|
|
289
|
+
rows?: () => number;
|
|
290
|
+
redraw?: () => void;
|
|
291
|
+
/** Milliseconds to wait after the last key before searching. */
|
|
292
|
+
delay?: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export class LibraryView {
|
|
296
|
+
private readonly o: LibraryOptions;
|
|
297
|
+
private query = "";
|
|
298
|
+
private items: LibraryItem[] = [];
|
|
299
|
+
private selected = 0;
|
|
300
|
+
private state: LibraryLayout["state"] = "idle";
|
|
301
|
+
private error: string | undefined;
|
|
302
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
303
|
+
private seq = 0;
|
|
304
|
+
constructor(o: LibraryOptions) {
|
|
305
|
+
this.o = o;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
render(width: number): string[] {
|
|
309
|
+
const height = Math.max(8, (this.o.rows?.() ?? 24) - 1);
|
|
310
|
+
return fill(
|
|
311
|
+
libraryLines({
|
|
312
|
+
width, rows: height, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs, researcher: this.o.researcher,
|
|
313
|
+
query: this.query, items: this.items, selected: this.selected, state: this.state, error: this.error,
|
|
314
|
+
}),
|
|
315
|
+
width,
|
|
316
|
+
height,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
invalidate(): void {}
|
|
321
|
+
|
|
322
|
+
dispose(): void {
|
|
323
|
+
clearTimeout(this.timer);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private schedule(): void {
|
|
327
|
+
clearTimeout(this.timer);
|
|
328
|
+
const q = this.query.replace(/\s+/g, " ").trim();
|
|
329
|
+
const mine = ++this.seq;
|
|
330
|
+
if (!q) {
|
|
331
|
+
this.items = [];
|
|
332
|
+
this.state = "idle";
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.state = "searching";
|
|
336
|
+
this.timer = setTimeout(async () => {
|
|
337
|
+
try {
|
|
338
|
+
const items = await this.o.search(q);
|
|
339
|
+
if (mine !== this.seq) return; // a newer search is on its way
|
|
340
|
+
this.items = items;
|
|
341
|
+
this.state = "done";
|
|
342
|
+
this.selected = 0;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (mine !== this.seq) return;
|
|
345
|
+
this.state = "failed";
|
|
346
|
+
this.error = (err as Error).message;
|
|
347
|
+
}
|
|
348
|
+
this.o.redraw?.();
|
|
349
|
+
}, this.o.delay ?? 250);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
handleInput(data: string): void {
|
|
353
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.o.done();
|
|
354
|
+
const item = this.items[this.selected];
|
|
355
|
+
if (matchesKey(data, "enter")) {
|
|
356
|
+
if (item) this.o.choose(item);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (matchesKey(data, "ctrl+n")) {
|
|
360
|
+
if (item && this.o.researcher) this.o.note(item);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
364
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(Math.max(0, this.items.length - 1), this.selected + 1);
|
|
365
|
+
else if (matchesKey(data, "backspace")) {
|
|
366
|
+
this.query = this.query.slice(0, -1);
|
|
367
|
+
this.schedule();
|
|
368
|
+
} else if (printable(data)) {
|
|
369
|
+
this.query += data;
|
|
370
|
+
this.schedule();
|
|
371
|
+
} else return;
|
|
372
|
+
this.o.redraw?.();
|
|
373
|
+
}
|
|
374
|
+
}
|
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
|
+
}
|