vkusvill 0.0.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/README.ru.md +89 -0
- package/bin/vv.js +12 -0
- package/package.json +39 -0
- package/src/api.js +228 -0
- package/src/app.js +328 -0
- package/src/cli.js +159 -0
- package/src/image.js +144 -0
- package/src/main.js +113 -0
- package/src/product.js +135 -0
- package/src/safe.js +37 -0
- package/src/store.js +190 -0
- package/src/sys.js +36 -0
- package/src/term/input.js +131 -0
- package/src/term/screen.js +204 -0
- package/src/term/width.js +74 -0
- package/src/ui/draw.js +324 -0
- package/src/ui/product-ui.js +192 -0
- package/src/ui/theme.js +47 -0
- package/src/ui/widgets.js +236 -0
- package/src/views/browser.js +251 -0
- package/src/views/cart.js +306 -0
- package/src/views/catalog.js +150 -0
- package/src/views/overlays.js +324 -0
- package/src/views/product.js +308 -0
- package/src/views/profile.js +228 -0
- package/src/views/recipes.js +358 -0
- package/src/views/shops.js +396 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Cell-based screen: drawing goes into a buffer, and flush sends only the changed cells to the terminal.
|
|
2
|
+
// Colors are 0xRRGGBB or -1 (terminal default). Terminals without truecolor get the nearest 256-palette color.
|
|
3
|
+
|
|
4
|
+
import { charWidth } from "./width.js";
|
|
5
|
+
|
|
6
|
+
export const A = { bold: 1, dim: 2, italic: 4, underline: 8, strike: 16 };
|
|
7
|
+
|
|
8
|
+
const ESC = "\x1b[";
|
|
9
|
+
|
|
10
|
+
function detectTruecolor() {
|
|
11
|
+
const env = process.env;
|
|
12
|
+
if (env.VV_COLOR === "256") return false;
|
|
13
|
+
if (env.VV_COLOR === "truecolor") return true;
|
|
14
|
+
if (/truecolor|24bit/i.test(env.COLORTERM ?? "")) return true;
|
|
15
|
+
if (/-direct|truecolor/.test(env.TERM ?? "")) return true;
|
|
16
|
+
if (env.TERM_PROGRAM === "Apple_Terminal") return false;
|
|
17
|
+
return ["iTerm.app", "WezTerm", "vscode", "ghostty", "Hyper", "Tabby", "rio"].includes(env.TERM_PROGRAM) || !!env.KITTY_WINDOW_ID || !!env.WT_SESSION;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Nearest xterm-256 palette color: the 6×6×6 cube or the grayscale ramp
|
|
21
|
+
const CUBE = [0, 95, 135, 175, 215, 255];
|
|
22
|
+
function to256(c) {
|
|
23
|
+
const r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
|
|
24
|
+
const idx = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.floor((v - 35) / 40));
|
|
25
|
+
const ri = idx(r), gi = idx(g), bi = idx(b);
|
|
26
|
+
const cr = CUBE[ri], cg = CUBE[gi], cb = CUBE[bi];
|
|
27
|
+
const dCube = (cr - r) ** 2 + (cg - g) ** 2 + (cb - b) ** 2;
|
|
28
|
+
const avg = (r + g + b) / 3;
|
|
29
|
+
const gi2 = Math.max(0, Math.min(23, Math.round((avg - 8) / 10)));
|
|
30
|
+
const gv = 8 + gi2 * 10;
|
|
31
|
+
const dGray = (gv - r) ** 2 + (gv - g) ** 2 + (gv - b) ** 2;
|
|
32
|
+
return dGray < dCube ? 232 + gi2 : 16 + 36 * ri + 6 * gi + bi;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Screen {
|
|
36
|
+
constructor(out = process.stdout) {
|
|
37
|
+
this.out = out;
|
|
38
|
+
this.truecolor = detectTruecolor();
|
|
39
|
+
this.w = 0;
|
|
40
|
+
this.h = 0;
|
|
41
|
+
this.clipStack = [];
|
|
42
|
+
this.resize(out.columns || 100, out.rows || 30);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resize(w, h) {
|
|
46
|
+
this.w = w;
|
|
47
|
+
this.h = h;
|
|
48
|
+
const n = w * h;
|
|
49
|
+
this.ch = new Array(n).fill(" ");
|
|
50
|
+
this.fg = new Int32Array(n).fill(-1);
|
|
51
|
+
this.bg = new Int32Array(n).fill(-1);
|
|
52
|
+
this.at = new Uint8Array(n);
|
|
53
|
+
this.pch = new Array(n).fill(null); // null: the terminal cell's state is unknown, redraw it
|
|
54
|
+
this.pfg = new Int32Array(n);
|
|
55
|
+
this.pbg = new Int32Array(n);
|
|
56
|
+
this.pat = new Uint8Array(n);
|
|
57
|
+
this.clip = { x0: 0, y0: 0, x1: w, y1: h };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
invalidateAll() {
|
|
61
|
+
this.pch.fill(null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pushClip(x, y, w, h) {
|
|
65
|
+
this.clipStack.push(this.clip);
|
|
66
|
+
const c = this.clip;
|
|
67
|
+
this.clip = { x0: Math.max(c.x0, x), y0: Math.max(c.y0, y), x1: Math.min(c.x1, x + w), y1: Math.min(c.y1, y + h) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
popClip() {
|
|
71
|
+
this.clip = this.clipStack.pop() ?? { x0: 0, y0: 0, x1: this.w, y1: this.h };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clear(bg = -1) {
|
|
75
|
+
this.ch.fill(" ");
|
|
76
|
+
this.fg.fill(-1);
|
|
77
|
+
this.bg.fill(bg);
|
|
78
|
+
this.at.fill(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A single cell; s is the character, null is the continuation of a wide character to the left
|
|
82
|
+
cell(x, y, s, fg, bg, at) {
|
|
83
|
+
const c = this.clip;
|
|
84
|
+
if (x < c.x0 || y < c.y0 || x >= c.x1 || y >= c.y1) return;
|
|
85
|
+
const i = y * this.w + x;
|
|
86
|
+
// Overwriting half of a wide character: the other half becomes a space
|
|
87
|
+
if (this.ch[i] === null && x > 0 && s !== null) this.ch[i - 1] = " ";
|
|
88
|
+
if (s !== null && x + 1 < this.w && this.ch[i + 1] === null) this.ch[i + 1] = " ";
|
|
89
|
+
this.ch[i] = s;
|
|
90
|
+
if (fg !== undefined && fg !== null) this.fg[i] = fg;
|
|
91
|
+
if (bg !== undefined && bg !== null) this.bg[i] = bg;
|
|
92
|
+
this.at[i] = at ?? 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Styled text; returns x after the text. Without bg, the cell's background is kept
|
|
96
|
+
put(x, y, str, st = {}) {
|
|
97
|
+
const at = (st.bold ? A.bold : 0) | (st.dim ? A.dim : 0) | (st.italic ? A.italic : 0) | (st.underline ? A.underline : 0) | (st.strike ? A.strike : 0);
|
|
98
|
+
const maxX = st.maxW !== undefined ? x + st.maxW : Infinity;
|
|
99
|
+
for (const ch of String(str)) {
|
|
100
|
+
const cp = ch.codePointAt(0);
|
|
101
|
+
const cw = charWidth(cp);
|
|
102
|
+
if (cw === 0) continue;
|
|
103
|
+
if (x + cw > maxX) break;
|
|
104
|
+
if (cw === 2 && x + 1 >= this.clip.x1) { this.cell(x, y, " ", st.fg, st.bg, at); x++; break; }
|
|
105
|
+
this.cell(x, y, ch, st.fg, st.bg, at);
|
|
106
|
+
if (cw === 2) this.cell(x + 1, y, null, st.fg, st.bg, at);
|
|
107
|
+
x += cw;
|
|
108
|
+
}
|
|
109
|
+
return x;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fill(x, y, w, h, bg, ch = " ", fg) {
|
|
113
|
+
for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.cell(i, j, ch, fg ?? -1, bg, 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Background only; characters and their colors stay
|
|
117
|
+
tint(x, y, w, h, bg) {
|
|
118
|
+
const c = this.clip;
|
|
119
|
+
for (let j = Math.max(y, c.y0); j < Math.min(y + h, c.y1); j++)
|
|
120
|
+
for (let i = Math.max(x, c.x0); i < Math.min(x + w, c.x1); i++) this.bg[j * this.w + i] = bg;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
color(c, isBg) {
|
|
124
|
+
if (c < 0) return isBg ? "49" : "39";
|
|
125
|
+
if (this.truecolor) return `${isBg ? 48 : 38};2;${(c >> 16) & 255};${(c >> 8) & 255};${c & 255}`;
|
|
126
|
+
return `${isBg ? 48 : 38};5;${to256(c)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
flush() {
|
|
130
|
+
let out = "";
|
|
131
|
+
let cx = -1, cy = -1, cfg = -2, cbg = -2, cat = -1;
|
|
132
|
+
const w = this.w;
|
|
133
|
+
for (let y = 0; y < this.h; y++) {
|
|
134
|
+
for (let x = 0; x < w; x++) {
|
|
135
|
+
const i = y * w + x;
|
|
136
|
+
const ch = this.ch[i];
|
|
137
|
+
if (ch === null) continue;
|
|
138
|
+
if (this.pch[i] === ch && this.pfg[i] === this.fg[i] && this.pbg[i] === this.bg[i] && this.pat[i] === this.at[i]) {
|
|
139
|
+
// a wide character with only its second half changed can't occur: the continuation is written together with it
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (cx !== x || cy !== y) out += `${ESC}${y + 1};${x + 1}H`;
|
|
143
|
+
const fg = this.fg[i], bg = this.bg[i], at = this.at[i];
|
|
144
|
+
if (fg !== cfg || bg !== cbg || at !== cat) {
|
|
145
|
+
let sgr = "0";
|
|
146
|
+
if (at & A.bold) sgr += ";1";
|
|
147
|
+
if (at & A.dim) sgr += ";2";
|
|
148
|
+
if (at & A.italic) sgr += ";3";
|
|
149
|
+
if (at & A.underline) sgr += ";4";
|
|
150
|
+
if (at & A.strike) sgr += ";9";
|
|
151
|
+
sgr += ";" + this.color(fg, false) + ";" + this.color(bg, true);
|
|
152
|
+
out += `${ESC}${sgr}m`;
|
|
153
|
+
cfg = fg; cbg = bg; cat = at;
|
|
154
|
+
}
|
|
155
|
+
out += ch;
|
|
156
|
+
const wide = x + 1 < w && this.ch[i + 1] === null;
|
|
157
|
+
this.pch[i] = ch; this.pfg[i] = fg; this.pbg[i] = bg; this.pat[i] = at;
|
|
158
|
+
if (wide) { this.pch[i + 1] = null; this.pfg[i + 1] = fg; this.pbg[i + 1] = bg; this.pat[i + 1] = at; }
|
|
159
|
+
cx = x + (wide ? 2 : 1);
|
|
160
|
+
cy = y;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (out) this.out.write(`\x1b[?2026h${out}${ESC}0m\x1b[?2026l`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
enter() {
|
|
167
|
+
this.out.write("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[2J");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
leave() {
|
|
171
|
+
this.out.write("\x1b[0m\x1b[?2004l\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[?1049l");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Buffer snapshot, for tests without a terminal
|
|
175
|
+
snapshot() {
|
|
176
|
+
const rows = [];
|
|
177
|
+
for (let y = 0; y < this.h; y++) {
|
|
178
|
+
let s = "";
|
|
179
|
+
for (let x = 0; x < this.w; x++) { const c = this.ch[y * this.w + x]; if (c !== null) s += c; }
|
|
180
|
+
rows.push(s);
|
|
181
|
+
}
|
|
182
|
+
return rows.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Snapshot as HTML, with colors
|
|
186
|
+
toHTML() {
|
|
187
|
+
const hex = (c, d) => (c < 0 ? d : "#" + c.toString(16).padStart(6, "0"));
|
|
188
|
+
let html = "";
|
|
189
|
+
for (let y = 0; y < this.h; y++) {
|
|
190
|
+
for (let x = 0; x < this.w; x++) {
|
|
191
|
+
const i = y * this.w + x;
|
|
192
|
+
const c = this.ch[i];
|
|
193
|
+
if (c === null) continue;
|
|
194
|
+
const wide = x + 1 < this.w && this.ch[i + 1] === null;
|
|
195
|
+
const at = this.at[i];
|
|
196
|
+
const style = `color:${hex(this.fg[i], "#ddd")};background:${hex(this.bg[i], "#111")}${at & A.bold ? ";font-weight:700" : ""}${at & A.dim ? ";opacity:.6" : ""}${at & A.italic ? ";font-style:italic" : ""}${at & A.underline ? ";text-decoration:underline" : ""}${at & A.strike ? ";text-decoration:line-through" : ""}${wide ? ";display:inline-block;width:2ch" : ""}`;
|
|
197
|
+
const e = c === "<" ? "<" : c === "&" ? "&" : c === ">" ? ">" : c;
|
|
198
|
+
html += `<span style="${style}">${e}</span>`;
|
|
199
|
+
}
|
|
200
|
+
html += "\n";
|
|
201
|
+
}
|
|
202
|
+
return `<!doctype html><meta charset="utf-8"><title>vv snapshot</title><style>body{background:#111;margin:0;padding:12px}pre{font:14px/1.0 "SF Mono",Menlo,monospace;margin:0;letter-spacing:0}span{display:inline-block;width:1ch;height:1.15em;vertical-align:top}</style><pre>${html}</pre>`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Terminal cell width of a character: 0 for combining marks, 2 for CJK and emoji, 1 otherwise.
|
|
2
|
+
|
|
3
|
+
const WIDE = [
|
|
4
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff],
|
|
5
|
+
[0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60],
|
|
6
|
+
[0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff],
|
|
7
|
+
[0x20000, 0x3fffd],
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function charWidth(cp) {
|
|
11
|
+
// C0 controls, DEL and C1 (\x9b is CSI) are never printed
|
|
12
|
+
if (cp < 0x300) return cp < 32 || (cp >= 0x7f && cp < 0xa0) ? 0 : 1;
|
|
13
|
+
if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069) || cp === 0xfeff || cp === 0xfe0f || cp === 0xfe0e) return 0;
|
|
14
|
+
for (const [a, b] of WIDE) if (cp >= a && cp <= b) return 2;
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function strWidth(s) {
|
|
19
|
+
let w = 0;
|
|
20
|
+
for (const ch of s) w += charWidth(ch.codePointAt(0));
|
|
21
|
+
return w;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Truncates a string to width w, ending it with "…" if it doesn't fit
|
|
25
|
+
export function truncate(s, w) {
|
|
26
|
+
if (w <= 0) return "";
|
|
27
|
+
if (strWidth(s) <= w) return s;
|
|
28
|
+
let out = "", cur = 0;
|
|
29
|
+
for (const ch of s) {
|
|
30
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
31
|
+
if (cur + cw > w - 1) break;
|
|
32
|
+
out += ch;
|
|
33
|
+
cur += cw;
|
|
34
|
+
}
|
|
35
|
+
return out + "…";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function padEnd(s, w) {
|
|
39
|
+
const t = truncate(s, w);
|
|
40
|
+
return t + " ".repeat(Math.max(0, w - strWidth(t)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function padStart(s, w) {
|
|
44
|
+
const t = truncate(s, w);
|
|
45
|
+
return " ".repeat(Math.max(0, w - strWidth(t))) + t;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Word wrap; overlong words are split
|
|
49
|
+
export function wrap(text, w) {
|
|
50
|
+
w = Math.max(1, Math.floor(w));
|
|
51
|
+
const lines = [];
|
|
52
|
+
for (const para of String(text).split("\n")) {
|
|
53
|
+
const start = lines.length;
|
|
54
|
+
let line = "";
|
|
55
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
56
|
+
let wd = word;
|
|
57
|
+
while (strWidth(wd) > w) {
|
|
58
|
+
if (line) { lines.push(line); line = ""; }
|
|
59
|
+
let cut = "";
|
|
60
|
+
for (const ch of wd) { if (strWidth(cut + ch) > w) break; cut += ch; }
|
|
61
|
+
if (!cut) cut = [...wd][0]; // a glyph wider than the line still has to go somewhere
|
|
62
|
+
lines.push(cut);
|
|
63
|
+
wd = wd.slice(cut.length);
|
|
64
|
+
}
|
|
65
|
+
if (!wd) continue;
|
|
66
|
+
if (!line) line = wd;
|
|
67
|
+
else if (strWidth(line) + 1 + strWidth(wd) <= w) line += " " + wd;
|
|
68
|
+
else { lines.push(line); line = wd; }
|
|
69
|
+
}
|
|
70
|
+
// An empty paragraph stays a blank line; a paragraph that ended on a split word adds nothing more
|
|
71
|
+
if (line || lines.length === start) lines.push(line);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
package/src/ui/draw.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Drawing on top of Screen: boxes, wrapped text, bars, stars, pills, pixel fonts,
|
|
2
|
+
// half-block photos, a braille canvas and QR codes.
|
|
3
|
+
|
|
4
|
+
import qrcode from "qrcode-generator";
|
|
5
|
+
import { T, mix, ramp } from "./theme.js";
|
|
6
|
+
import { strWidth, truncate, wrap } from "../term/width.js";
|
|
7
|
+
|
|
8
|
+
const BOX = {
|
|
9
|
+
round: ["╭", "╮", "╰", "╯", "─", "│"],
|
|
10
|
+
sharp: ["┌", "┐", "└", "┘", "─", "│"],
|
|
11
|
+
heavy: ["┏", "┓", "┗", "┛", "━", "┃"],
|
|
12
|
+
double: ["╔", "╗", "╚", "╝", "═", "║"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
16
|
+
export const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
17
|
+
|
|
18
|
+
// 3–5×5 pixel font for big prices
|
|
19
|
+
const DIGITS = {
|
|
20
|
+
"0": ["###", "#.#", "#.#", "#.#", "###"], "1": [".#.", "##.", ".#.", ".#.", "###"],
|
|
21
|
+
"2": ["###", "..#", "###", "#..", "###"], "3": ["###", "..#", ".##", "..#", "###"],
|
|
22
|
+
"4": ["#.#", "#.#", "###", "..#", "..#"], "5": ["###", "#..", "###", "..#", "###"],
|
|
23
|
+
"6": ["###", "#..", "###", "#.#", "###"], "7": ["###", "..#", "..#", ".#.", ".#."],
|
|
24
|
+
"8": ["###", "#.#", "###", "#.#", "###"], "9": ["###", "#.#", "###", "..#", "###"],
|
|
25
|
+
"₽": [".###.", ".#..#", "####.", ".#...", ".#..."], " ": ["..", "..", "..", "..", ".."],
|
|
26
|
+
",": [".", ".", ".", ".", "#"], ".": [".", ".", ".", ".", "#"], "−": ["...", "...", "###", "...", "..."],
|
|
27
|
+
"%": ["#..#", "...#", "..#.", ".#..", "#..#"], "×": ["...", "#.#", ".#.", "#.#", "..."],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Big digits in the style of the postal code on a Russian envelope: a 5×5 grid of two squares with diagonals.
|
|
31
|
+
// t m b are horizontals; lu ld ru rd are vertical halves; du, dd are the diagonals of the upper and lower square
|
|
32
|
+
const POSTAL = {
|
|
33
|
+
"0": "t b lu ld ru rd", "1": "du ru rd", "2": "t ru dd b", "3": "t du m rd b", "4": "lu m ru rd",
|
|
34
|
+
"5": "t lu m rd b", "6": "du ld m rd b", "7": "t du ld", "8": "t m b lu ld ru rd", "9": "t lu ru m dd",
|
|
35
|
+
};
|
|
36
|
+
const POSTAL_RUB = [".####", ".#..#", "####.", ".#...", ".#..."];
|
|
37
|
+
|
|
38
|
+
function postalGlyph(c, W = 5, H = 5) {
|
|
39
|
+
if (c === "₽") return POSTAL_RUB.map((r) => [...r].map((ch) => ch === "#"));
|
|
40
|
+
const g = Array.from({ length: H }, () => Array(W).fill(false));
|
|
41
|
+
const M = (H - 1) / 2;
|
|
42
|
+
const P = { TL: [0, 0], TR: [W - 1, 0], ML: [0, M], MR: [W - 1, M], BL: [0, H - 1], BR: [W - 1, H - 1] };
|
|
43
|
+
const SEG = { t: ["TL", "TR"], m: ["ML", "MR"], b: ["BL", "BR"], lu: ["TL", "ML"], ld: ["ML", "BL"], ru: ["TR", "MR"], rd: ["MR", "BR"], du: ["TR", "ML"], dd: ["MR", "BL"] };
|
|
44
|
+
for (const seg of POSTAL[c].split(" ")) {
|
|
45
|
+
const [[x0, y0], [x1, y1]] = SEG[seg].map((k) => P[k]);
|
|
46
|
+
const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
|
|
47
|
+
for (let i = 0; i <= n; i++) g[Math.round(y0 + ((y1 - y0) * i) / n)][Math.round(x0 + ((x1 - x0) * i) / n)] = true;
|
|
48
|
+
}
|
|
49
|
+
return g;
|
|
50
|
+
}
|
|
51
|
+
const POSTAL_CACHE = new Map();
|
|
52
|
+
|
|
53
|
+
// 5×7 pixel font for the logo
|
|
54
|
+
const LOGO = {
|
|
55
|
+
"В": ["####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."],
|
|
56
|
+
"К": ["#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"],
|
|
57
|
+
"У": ["#...#", "#...#", "#...#", ".####", "....#", "#...#", ".###."],
|
|
58
|
+
"С": [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
|
|
59
|
+
"И": ["#...#", "#...#", "#..##", "#.#.#", "##..#", "#...#", "#...#"],
|
|
60
|
+
"Л": ["..###", ".#..#", ".#..#", ".#..#", ".#..#", ".#..#", "##..#"],
|
|
61
|
+
"Д": ["..##.", ".#.#.", ".#.#.", ".#.#.", "#..#.", "#####", "#...#"],
|
|
62
|
+
"Е": ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
|
|
63
|
+
" ": ["...", "...", "...", "...", "...", "...", "..."],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const LOGO_GLYPH = (c) => LOGO[c] ?? LOGO[" "];
|
|
67
|
+
|
|
68
|
+
// VkusVill leaf, 10×7
|
|
69
|
+
export const LEAF = [
|
|
70
|
+
".......###",
|
|
71
|
+
".....#####",
|
|
72
|
+
"...###.###",
|
|
73
|
+
"..###.####",
|
|
74
|
+
".###.####.",
|
|
75
|
+
".#.#####..",
|
|
76
|
+
"#.........",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export class G {
|
|
80
|
+
constructor(screen, app) {
|
|
81
|
+
this.s = screen;
|
|
82
|
+
this.app = app;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get w() { return this.s.w; }
|
|
86
|
+
get h() { return this.s.h; }
|
|
87
|
+
|
|
88
|
+
bgAt(x, y) {
|
|
89
|
+
if (x < 0 || y < 0 || x >= this.s.w || y >= this.s.h) return T.bg;
|
|
90
|
+
return this.s.bg[y * this.s.w + x];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
text(x, y, str, st = {}) { return this.s.put(x, y, str, st); }
|
|
94
|
+
|
|
95
|
+
// Text truncated to width, ending with "…"
|
|
96
|
+
textW(x, y, str, w, st = {}) { return this.s.put(x, y, truncate(String(str), w), st); }
|
|
97
|
+
|
|
98
|
+
textR(xr, y, str, st = {}) { const w = strWidth(str); this.s.put(xr - w, y, str, st); return xr - w; }
|
|
99
|
+
|
|
100
|
+
textC(x, w, y, str, st = {}) { const t = truncate(str, w); this.s.put(x + Math.floor((w - strWidth(t)) / 2), y, t, st); }
|
|
101
|
+
|
|
102
|
+
fill(x, y, w, h, bg) { this.s.fill(x, y, w, h, bg); }
|
|
103
|
+
tint(x, y, w, h, bg) { this.s.tint(x, y, w, h, bg); }
|
|
104
|
+
|
|
105
|
+
// Differently styled runs in a row: [[text, style], …]
|
|
106
|
+
spans(x, y, parts, maxW = Infinity) {
|
|
107
|
+
const end = x + maxW;
|
|
108
|
+
for (const [t, st] of parts) {
|
|
109
|
+
if (x >= end) break;
|
|
110
|
+
x = this.s.put(x, y, truncate(String(t), end - x), st ?? {});
|
|
111
|
+
}
|
|
112
|
+
return x;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
para(x, y, w, h, str, st = {}) {
|
|
116
|
+
const lines = wrap(str, w);
|
|
117
|
+
lines.slice(0, h).forEach((l, i) => this.s.put(x, y + i, i === h - 1 && lines.length > h ? truncate(l + " …", w) : l, st));
|
|
118
|
+
return Math.min(lines.length, h);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
hline(x, y, w, fg = T.line, ch = "─") { this.s.put(x, y, ch.repeat(Math.max(0, w)), { fg }); }
|
|
122
|
+
vline(x, y, h, fg = T.line, ch = "│") { for (let i = 0; i < h; i++) this.s.put(x, y + i, ch, { fg }); }
|
|
123
|
+
|
|
124
|
+
box(x, y, w, h, { fg = T.line, bg, style = "round", title, titleFg = T.sub, right, rightFg = T.dim } = {}) {
|
|
125
|
+
if (w < 2 || h < 2) return;
|
|
126
|
+
const [tl, tr, bl, br, hz, vt] = BOX[style];
|
|
127
|
+
if (bg !== undefined) this.s.fill(x, y, w, h, bg);
|
|
128
|
+
this.s.put(x, y, tl + hz.repeat(w - 2) + tr, { fg });
|
|
129
|
+
for (let j = 1; j < h - 1; j++) { this.s.put(x, y + j, vt, { fg }); this.s.put(x + w - 1, y + j, vt, { fg }); }
|
|
130
|
+
this.s.put(x, y + h - 1, bl + hz.repeat(w - 2) + br, { fg });
|
|
131
|
+
if (title) this.s.put(x + 2, y, truncate(` ${title} `, w - 4), { fg: titleFg, bold: true });
|
|
132
|
+
if (right) { const r = truncate(` ${right} `, Math.max(0, w - 8 - (title ? strWidth(title) : 0))); this.s.put(x + w - 2 - strWidth(r), y, r, { fg: rightFg }); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Horizontal bar with 1/8-cell precision
|
|
136
|
+
// Full cells are background fill, solid at any line spacing; only the fractional tip is a ▏…▉ glyph
|
|
137
|
+
bar(x, y, w, frac, fg, bg = T.raise) {
|
|
138
|
+
frac = Math.max(0, Math.min(1, frac || 0));
|
|
139
|
+
const total = frac * w;
|
|
140
|
+
let full = Math.floor(total);
|
|
141
|
+
let rem = Math.round((total - full) * 8);
|
|
142
|
+
if (rem === 8) { full++; rem = 0; }
|
|
143
|
+
this.s.put(x, y, " ".repeat(w), { bg });
|
|
144
|
+
if (full) this.s.put(x, y, " ".repeat(full), { bg: fg });
|
|
145
|
+
if (rem && full < w) this.s.put(x + full, y, EIGHTHS[rem], { fg, bg });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Gradient bar, same construction
|
|
149
|
+
gradBar(x, y, w, frac, stops, bg = T.raise) {
|
|
150
|
+
const total = Math.max(0, Math.min(1, frac)) * w;
|
|
151
|
+
for (let i = 0; i < w; i++) {
|
|
152
|
+
const c = ramp(stops, w > 1 ? i / (w - 1) : 0);
|
|
153
|
+
const rem = Math.round((total - i) * 8);
|
|
154
|
+
if (i + 1 <= total || rem >= 8) this.s.put(x + i, y, " ", { bg: c });
|
|
155
|
+
else if (i < total && rem > 0) this.s.put(x + i, y, EIGHTHS[rem], { fg: c, bg });
|
|
156
|
+
else this.s.put(x + i, y, " ", { bg });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
stars(x, y, rating, { bg } = {}) {
|
|
161
|
+
return this.s.put(x, y, "★", { fg: rating ? T.yellow : T.mute, bg });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
starsRow(x, y, rating, { bg } = {}) {
|
|
165
|
+
let out = x;
|
|
166
|
+
for (let i = 1; i <= 5; i++) {
|
|
167
|
+
const on = rating >= i - 0.25;
|
|
168
|
+
const half = !on && rating >= i - 0.75;
|
|
169
|
+
out = this.s.put(out, y, on || half ? "★" : "☆", { fg: on ? T.yellow : half ? mix(T.yellow, T.mute, 0.55) : T.mute, bg });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pill: a label on a fill, padded with spaces. Half-block rounded ends ▐▌ don't work:
|
|
175
|
+
// in some fonts they are wider than a cell and eat the last letter
|
|
176
|
+
pill(x, y, label, { fg = T.text, bg = T.raise, bold = false } = {}) {
|
|
177
|
+
return this.s.put(x, y, ` ${label} `, { fg, bg, bold });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pillWidth(label) { return strWidth(label) + 2; }
|
|
181
|
+
|
|
182
|
+
// A key in the hint bar
|
|
183
|
+
key(x, y, k, label, { active = false } = {}) {
|
|
184
|
+
x = this.s.put(x, y, ` ${k} `, { fg: active ? T.bg : T.lime, bg: active ? T.lime : T.raise, bold: true });
|
|
185
|
+
return this.s.put(x, y, ` ${label} `, { fg: T.sub });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bitmap drawn with half blocks: px(x, y) → color or -1 (transparent)
|
|
189
|
+
bitmap(x, y, pw, ph, px) {
|
|
190
|
+
for (let cy = 0; cy < Math.ceil(ph / 2); cy++) {
|
|
191
|
+
for (let cx = 0; cx < pw; cx++) {
|
|
192
|
+
const top = px(cx, cy * 2);
|
|
193
|
+
const bot = cy * 2 + 1 < ph ? px(cx, cy * 2 + 1) : -1;
|
|
194
|
+
if (top < 0 && bot < 0) continue;
|
|
195
|
+
const under = this.bgAt(x + cx, y + cy);
|
|
196
|
+
if (top >= 0 && bot >= 0) this.s.cell(x + cx, y + cy, top === bot ? " " : "▀", top, bot, 0);
|
|
197
|
+
else if (top >= 0) this.s.cell(x + cx, y + cy, "▀", top, under, 0);
|
|
198
|
+
else this.s.cell(x + cx, y + cy, "▄", bot, under, 0);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Photo from image.js
|
|
204
|
+
photo(x, y, img) {
|
|
205
|
+
if (!img || img.error) return;
|
|
206
|
+
this.bitmap(x, y, img.w, img.h, (px, py) => img.px[py * img.w + px]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Placeholder while the photo loads: shimmering "dust"
|
|
210
|
+
photoPlaceholder(x, y, w, h, t = 0, label = "") {
|
|
211
|
+
for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
|
|
212
|
+
const v = (Math.sin(i * 0.35 + j * 0.6 + t * 0.12) + 1) / 2;
|
|
213
|
+
this.s.cell(x + i, y + j, "▒░·"[Math.floor(v * 2.99)], mix(T.panel2, T.line, v), T.panel, 0);
|
|
214
|
+
}
|
|
215
|
+
if (label && h > 0) this.textC(x, w, y + Math.floor(h / 2), ` ${label} `, { fg: T.dim, bg: T.panel });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A line in a pixel font; color is color(px, py) or a number
|
|
219
|
+
pixelText(x, y, str, font, color, gap = 1) {
|
|
220
|
+
const rows = font === "logo" ? 7 : 5;
|
|
221
|
+
const map = font === "logo" ? LOGO : DIGITS;
|
|
222
|
+
const glyphs = [...str].map((c) => map[c] ?? map[" "]);
|
|
223
|
+
const width = glyphs.reduce((s, g) => s + g[0].length + gap, 0) - gap;
|
|
224
|
+
const col = typeof color === "function" ? color : () => color;
|
|
225
|
+
const grid = (px, py) => {
|
|
226
|
+
let ox = 0;
|
|
227
|
+
for (const g of glyphs) {
|
|
228
|
+
const gw = g[0].length;
|
|
229
|
+
if (px < ox + gw) return px >= ox && g[py]?.[px - ox] === "#" ? col(px, py, width) : -1;
|
|
230
|
+
ox += gw + gap;
|
|
231
|
+
}
|
|
232
|
+
return -1;
|
|
233
|
+
};
|
|
234
|
+
this.bitmap(x, y, width, rows, grid);
|
|
235
|
+
return width;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
pixelWidth(str, font = "digits", gap = 1) {
|
|
239
|
+
const map = font === "logo" ? LOGO : DIGITS;
|
|
240
|
+
return [...str].reduce((s, c) => s + (map[c] ?? map[" "])[0].length + gap, 0) - gap;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Big price "1 245 ₽" in postal-code digits, 5 rows. A pixel is a cell filled with background:
|
|
244
|
+
// the fill also covers the line spacing, so no terminal shows gaps
|
|
245
|
+
bigPrice(x, y, n, color = T.lime) {
|
|
246
|
+
const s = `${Math.round(n).toLocaleString("ru-RU").replace(/\s|\u00a0|\u202f/g, " ")} ₽`;
|
|
247
|
+
let cx = x;
|
|
248
|
+
for (const c of s) {
|
|
249
|
+
if (c === " ") { cx += 2; continue; }
|
|
250
|
+
if (!POSTAL_CACHE.has(c)) POSTAL_CACHE.set(c, postalGlyph(c));
|
|
251
|
+
const g = POSTAL_CACHE.get(c);
|
|
252
|
+
g.forEach((row, py) => row.forEach((on, px) => { if (on) this.s.cell(cx + px, y + py, " ", -1, color, 0); }));
|
|
253
|
+
cx += g[0].length + 1;
|
|
254
|
+
}
|
|
255
|
+
return cx - x - 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
static bigPriceH = 5;
|
|
259
|
+
|
|
260
|
+
qr(x, y, text) {
|
|
261
|
+
const q = qrcode(0, "L");
|
|
262
|
+
q.addData(text);
|
|
263
|
+
q.make();
|
|
264
|
+
const n = q.getModuleCount();
|
|
265
|
+
const quiet = 2;
|
|
266
|
+
const size = n + quiet * 2;
|
|
267
|
+
this.bitmap(x, y, size, size + (size % 2), (px, py) => {
|
|
268
|
+
const r = py - quiet, c = px - quiet;
|
|
269
|
+
if (r < 0 || c < 0 || r >= n || c >= n) return T.white;
|
|
270
|
+
return q.isDark(r, c) ? T.black : T.white;
|
|
271
|
+
});
|
|
272
|
+
return { w: size, h: Math.ceil(size / 2) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
static qrSize(text) {
|
|
276
|
+
const q = qrcode(0, "L");
|
|
277
|
+
q.addData(text);
|
|
278
|
+
q.make();
|
|
279
|
+
const size = q.getModuleCount() + 4;
|
|
280
|
+
return { w: size, h: Math.ceil(size / 2) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Scrollbar to the right of a list
|
|
284
|
+
scrollbar(x, y, h, top, visible, total) {
|
|
285
|
+
if (total <= visible || h < 2) return;
|
|
286
|
+
const th = Math.max(1, Math.round((visible / total) * h));
|
|
287
|
+
const ty = Math.round((top / Math.max(1, total - visible)) * (h - th));
|
|
288
|
+
for (let i = 0; i < h; i++) this.s.put(x, y + i, i >= ty && i < ty + th ? "┃" : "│", { fg: i >= ty && i < ty + th ? T.green : T.line });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Braille canvas: 2×4 dots per cell
|
|
293
|
+
export class Braille {
|
|
294
|
+
constructor(w, h) {
|
|
295
|
+
this.w = w; this.h = h;
|
|
296
|
+
this.bits = new Uint8Array(w * h);
|
|
297
|
+
this.col = new Int32Array(w * h).fill(-1);
|
|
298
|
+
this.pri = new Uint8Array(w * h);
|
|
299
|
+
}
|
|
300
|
+
get pw() { return this.w * 2; }
|
|
301
|
+
get ph() { return this.h * 4; }
|
|
302
|
+
set(px, py, color, pri = 0) {
|
|
303
|
+
px = Math.floor(px); py = Math.floor(py);
|
|
304
|
+
if (px < 0 || py < 0 || px >= this.w * 2 || py >= this.h * 4) return;
|
|
305
|
+
const cx = px >> 1, cy = py >> 2, i = cy * this.w + cx;
|
|
306
|
+
const dx = px & 1, dy = py & 3;
|
|
307
|
+
const bit = dy === 3 ? (dx ? 0x80 : 0x40) : dx ? 0x08 << dy : 0x01 << dy;
|
|
308
|
+
this.bits[i] |= bit;
|
|
309
|
+
if (pri >= this.pri[i]) { this.col[i] = color; this.pri[i] = pri; }
|
|
310
|
+
}
|
|
311
|
+
line(x0, y0, x1, y1, color, pri) {
|
|
312
|
+
const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
|
|
313
|
+
for (let i = 0; i <= n; i++) this.set(x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, color, pri);
|
|
314
|
+
}
|
|
315
|
+
draw(g, x, y) {
|
|
316
|
+
for (let j = 0; j < this.h; j++) this.drawRow(g, x, y + j, j);
|
|
317
|
+
}
|
|
318
|
+
drawRow(g, x, y, j) {
|
|
319
|
+
for (let i = 0; i < this.w; i++) {
|
|
320
|
+
const b = this.bits[j * this.w + i];
|
|
321
|
+
if (b) g.s.cell(x + i, y, String.fromCharCode(0x2800 + b), this.col[j * this.w + i], g.bgAt(x + i, y), 0);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|