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,192 @@
|
|
|
1
|
+
// Product rendering: list row, side card, composition flags, macro energy split, ingredient highlighting.
|
|
2
|
+
|
|
3
|
+
import { T, mix } from "./theme.js";
|
|
4
|
+
import { RX, rub, compactNum, qtyText } from "../product.js";
|
|
5
|
+
import { strWidth, truncate } from "../term/width.js";
|
|
6
|
+
import { image } from "../image.js";
|
|
7
|
+
import { richWrap, drawRich } from "./widgets.js";
|
|
8
|
+
import { store } from "../store.js";
|
|
9
|
+
|
|
10
|
+
export const perKg = (p) => (p.unit === "кг" ? null : p.grams ? Math.round((p.price / p.grams) * 1000) : null);
|
|
11
|
+
|
|
12
|
+
// Composition flags → pills
|
|
13
|
+
export function flagPills(p) {
|
|
14
|
+
if (!p.comp) return [];
|
|
15
|
+
const f = p.flags ?? {};
|
|
16
|
+
const out = [];
|
|
17
|
+
out.push(f.sugar ? ["сахар", T.red] : ["без сахара", T.green]);
|
|
18
|
+
if (f.gluten) out.push(["глютен", T.yellow]);
|
|
19
|
+
if (f.lactose) out.push(["лактоза", T.yellow]);
|
|
20
|
+
if (f.additives) out.push([`добавки ×${f.additives}`, T.orange]);
|
|
21
|
+
if (f.palm) out.push(["пальм. масло", T.red]);
|
|
22
|
+
if (!f.sugar && !f.additives && !f.palm && !f.gluten && !f.lactose) out.push(["чистый состав", T.lime]);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function drawPills(g, x, y, w, pills) {
|
|
27
|
+
let cx = x;
|
|
28
|
+
let row = 0;
|
|
29
|
+
for (const [label, color] of pills) {
|
|
30
|
+
const pw = strWidth(label) + 2;
|
|
31
|
+
if (cx + pw > x + w) { row++; cx = x; }
|
|
32
|
+
g.pill(cx, y + row, label, { fg: color, bg: mix(color, T.panel, 0.82) });
|
|
33
|
+
cx += pw + 1;
|
|
34
|
+
}
|
|
35
|
+
return row + 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Share of energy from protein, fat and carbs, as a single bar
|
|
39
|
+
export function energyBar(g, x, y, w, p, bg = T.panel) {
|
|
40
|
+
const e = [(p.p ?? 0) * 4, (p.f ?? 0) * 9, (p.c ?? 0) * 4];
|
|
41
|
+
const sum = e[0] + e[1] + e[2];
|
|
42
|
+
if (!sum) { g.text(x, y, "нет данных о КБЖУ", { fg: T.dim, bg }); return; }
|
|
43
|
+
const cols = [T.blue, T.yellow, T.orange];
|
|
44
|
+
// Background fill rather than █: solid at any line spacing
|
|
45
|
+
for (let i = 0; i < w; i++) {
|
|
46
|
+
const t = ((i + 0.5) / w) * sum;
|
|
47
|
+
const k = t > e[0] + e[1] ? 2 : t > e[0] ? 1 : 0;
|
|
48
|
+
g.text(x + i, y, " ", { bg: cols[k] });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function kbjuLine(g, x, y, w, p, bg) {
|
|
53
|
+
const n = (v) => (v == null ? "—" : String(Math.round(v * 10) / 10).replace(".", ","));
|
|
54
|
+
return g.spans(x, y, [
|
|
55
|
+
["Б ", { fg: T.blue, bold: true, bg }], [n(p.p) + " ", { fg: T.text, bg }],
|
|
56
|
+
["Ж ", { fg: T.yellow, bold: true, bg }], [n(p.f) + " ", { fg: T.text, bg }],
|
|
57
|
+
["У ", { fg: T.orange, bold: true, bg }], [n(p.c) + " ", { fg: T.text, bg }],
|
|
58
|
+
[p.kcal != null ? `${Math.round(p.kcal)} ккал` : "", { fg: T.lime, bold: true, bg }],
|
|
59
|
+
], w);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ingredient highlighting: sugar and palm oil in red, additives in orange, gluten and lactose in yellow
|
|
63
|
+
export function compositionLines(comp, w) {
|
|
64
|
+
const ranges = [];
|
|
65
|
+
const scan = (re, color) => {
|
|
66
|
+
const r = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
67
|
+
let m;
|
|
68
|
+
while ((m = r.exec(comp))) { ranges.push([m.index, m.index + m[0].length, color]); if (!m[0].length) r.lastIndex++; }
|
|
69
|
+
};
|
|
70
|
+
scan(RX.sugar, T.red); scan(RX.additives, T.orange); scan(RX.palm, T.red);
|
|
71
|
+
scan(RX.gluten, T.yellow); scan(RX.lactose, T.yellow);
|
|
72
|
+
return richWrap(comp, w, (i, s) => {
|
|
73
|
+
const hit = ranges.find(([a, b]) => a < i + s.length && b > i);
|
|
74
|
+
return hit ? { fg: hit[2], bold: true } : null;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Product list row
|
|
79
|
+
export function productRow(g, p, x, y, w, { selected, focused = true, mark, bg: rowBg } = {}) {
|
|
80
|
+
const bg = selected ? (focused ? T.selHi : T.sel) : rowBg ?? T.panel;
|
|
81
|
+
g.fill(x, y, w, 1, bg);
|
|
82
|
+
if (selected) g.text(x, y, "▌", { fg: focused ? T.lime : T.green, bg });
|
|
83
|
+
const q = store.qty(p.id);
|
|
84
|
+
const fav = store.isFav(p.id);
|
|
85
|
+
// Columns, right to left
|
|
86
|
+
let rx = x + w - 1;
|
|
87
|
+
const cartW = 6;
|
|
88
|
+
if (q) g.textR(rx, y, p.unit === "кг" ? `${+q.toFixed(1)}кг` : `×${q}`, { fg: T.lime, bg, bold: true });
|
|
89
|
+
else if (mark) g.textR(rx, y, mark, { fg: T.violet, bg, bold: true });
|
|
90
|
+
rx -= cartW;
|
|
91
|
+
g.textR(rx, y, rub(p.price) + (p.unit === "кг" ? "/кг" : ""), { fg: p.old ? T.orange : T.white, bg, bold: true });
|
|
92
|
+
rx -= 12;
|
|
93
|
+
if (w > 54) {
|
|
94
|
+
if (p.off) g.textR(rx, y, `−${p.off}%`, { fg: T.orange, bg, bold: true });
|
|
95
|
+
rx -= 6;
|
|
96
|
+
}
|
|
97
|
+
if (w > 74) {
|
|
98
|
+
const pk = perKg(p);
|
|
99
|
+
if (pk) g.textR(rx, y, `${pk.toLocaleString("ru-RU").replace(/\s/g, " ")} ₽/кг`, { fg: T.dim, bg });
|
|
100
|
+
rx -= 12;
|
|
101
|
+
}
|
|
102
|
+
if (w > 64) {
|
|
103
|
+
if (p.rating) g.spans(rx - 6, y, [["★ ", { fg: T.yellow, bg }], [p.rating.toFixed(1).replace(".", ","), { fg: T.sub, bg }]]);
|
|
104
|
+
rx -= 7;
|
|
105
|
+
}
|
|
106
|
+
let cx = x + 2;
|
|
107
|
+
if (fav) cx = g.text(cx, y, "♥ ", { fg: T.pink, bg });
|
|
108
|
+
const nameW = rx - cx;
|
|
109
|
+
if (nameW > 4) {
|
|
110
|
+
const t = truncate(p.title, nameW);
|
|
111
|
+
cx = g.text(cx, y, t, { fg: selected ? T.white : T.text, bg, bold: selected });
|
|
112
|
+
if (p.pack && strWidth(t) < nameW - 2 && t === p.title) g.textW(cx + 1, y, p.pack, nameW - strWidth(t) - 1, { fg: T.dim, bg });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Side product card
|
|
117
|
+
export function productPane(g, app, p, x, y, w, h, { compact = false } = {}) {
|
|
118
|
+
g.fill(x, y, w, h, T.panel);
|
|
119
|
+
if (!p) { g.textC(x, w, y + Math.floor(h / 2), "выберите товар", { fg: T.dim, bg: T.panel }); return; }
|
|
120
|
+
let cy = y;
|
|
121
|
+
const iw = w - 2;
|
|
122
|
+
const photoH = compact ? 0 : Math.max(0, Math.min(Math.floor(h * 0.36), Math.round(iw * 0.3)));
|
|
123
|
+
if (photoH >= 4) {
|
|
124
|
+
const img = image(p.thumb ?? p.images?.[0], iw, photoH);
|
|
125
|
+
if (img && !img.error) {
|
|
126
|
+
const ox = x + 1 + Math.floor((iw - img.w) / 2);
|
|
127
|
+
g.photo(ox, cy, img);
|
|
128
|
+
cy += Math.ceil(img.h / 2);
|
|
129
|
+
} else {
|
|
130
|
+
g.photoPlaceholder(x + 1, cy, iw, photoH, app.frame, img?.error ? "без фото" : "загружаю фото…");
|
|
131
|
+
cy += photoH;
|
|
132
|
+
}
|
|
133
|
+
cy++;
|
|
134
|
+
}
|
|
135
|
+
const end = y + h;
|
|
136
|
+
const left = () => end - cy;
|
|
137
|
+
// Title
|
|
138
|
+
cy += g.para(x + 1, cy, iw, 2, p.title, { fg: T.white, bold: true, bg: T.panel });
|
|
139
|
+
const meta = [p.pack, p.category?.at(-1)].filter(Boolean).join(" · ");
|
|
140
|
+
if (meta && left() > 0) g.textW(x + 1, cy++, meta, iw, { fg: T.dim, bg: T.panel });
|
|
141
|
+
cy++;
|
|
142
|
+
// Big price
|
|
143
|
+
if (left() > 7) {
|
|
144
|
+
const pw = g.bigPrice(x + 1, cy, p.price, p.old ? T.orange : T.lime);
|
|
145
|
+
let tx = x + 2 + pw + 1;
|
|
146
|
+
if (p.old && tx + 8 < x + w) {
|
|
147
|
+
g.text(tx, cy + 1, rub(p.old), { fg: T.dim, strike: true, bg: T.panel });
|
|
148
|
+
g.pill(tx, cy + 3, `−${p.off}%`, { fg: T.bg, bg: T.orange, bold: true });
|
|
149
|
+
} else if (p.unit === "кг" && tx + 6 < x + w) g.text(tx, cy + 4, "за кг", { fg: T.dim, bg: T.panel });
|
|
150
|
+
cy += 6;
|
|
151
|
+
if (p.offInfo && left() > 0) g.textW(x + 1, cy++, p.offInfo, iw, { fg: T.orange, bg: T.panel, italic: true });
|
|
152
|
+
} else {
|
|
153
|
+
g.text(x + 1, cy++, rub(p.price), { fg: T.lime, bold: true });
|
|
154
|
+
}
|
|
155
|
+
// Rating and price per kg
|
|
156
|
+
if (left() > 0) {
|
|
157
|
+
let cx = x + 1;
|
|
158
|
+
if (p.rating) {
|
|
159
|
+
cx = g.stars(cx, cy, p.rating, { bg: T.panel });
|
|
160
|
+
cx = g.text(cx + 1, cy, p.rating.toFixed(1).replace(".", ","), { fg: T.yellow, bold: true, bg: T.panel });
|
|
161
|
+
cx = g.text(cx + 1, cy, `· ${compactNum(p.votes)}`, { fg: T.dim, bg: T.panel });
|
|
162
|
+
}
|
|
163
|
+
const pk = perKg(p);
|
|
164
|
+
if (pk) g.textR(x + w - 1, cy, `${pk.toLocaleString("ru-RU").replace(/\s/g, " ")} ₽/кг`, { fg: T.sub, bg: T.panel });
|
|
165
|
+
cy += 2;
|
|
166
|
+
}
|
|
167
|
+
// Kcal and macros
|
|
168
|
+
if (left() > 2 && (p.kcal != null || p.p != null)) {
|
|
169
|
+
g.text(x + 1, cy, "на 100 г", { fg: T.dim, bg: T.panel });
|
|
170
|
+
kbjuLine(g, x + 10, cy++, iw - 9, p, T.panel);
|
|
171
|
+
energyBar(g, x + 1, cy++, iw, p);
|
|
172
|
+
cy++;
|
|
173
|
+
}
|
|
174
|
+
// Composition flags
|
|
175
|
+
const pills = flagPills(p);
|
|
176
|
+
if (pills.length && left() > 1) { cy += drawPills(g, x + 1, cy, iw, pills); cy++; }
|
|
177
|
+
// Ingredients
|
|
178
|
+
if (p.comp && left() > 1) {
|
|
179
|
+
g.text(x + 1, cy++, "СОСТАВ", { fg: T.dim, bold: true, bg: T.panel });
|
|
180
|
+
const lines = compositionLines(p.comp, iw);
|
|
181
|
+
const n = drawRich(g, x + 1, cy, iw, left(), lines, { fg: T.sub, bg: T.panel });
|
|
182
|
+
if (lines.length > n && n > 0) g.textR(x + w - 1, cy + n - 1, " …", { fg: T.dim, bg: T.panel });
|
|
183
|
+
cy += n;
|
|
184
|
+
} else if (p.description && left() > 1) {
|
|
185
|
+
cy += g.para(x + 1, cy, iw, left(), p.description, { fg: T.sub, bg: T.panel });
|
|
186
|
+
}
|
|
187
|
+
const q = store.qty(p.id);
|
|
188
|
+
if (q && h > 6) {
|
|
189
|
+
const label = ` в корзине: ${qtyText(p, q)} `;
|
|
190
|
+
g.textR(x + w - 1, y, label, { fg: T.bg, bg: T.lime, bold: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Palette: VkusVill's dark greens. Colors are 0xRRGGBB.
|
|
2
|
+
|
|
3
|
+
export const T = {
|
|
4
|
+
bg: 0x0b130e,
|
|
5
|
+
panel: 0x0f1a13,
|
|
6
|
+
panel2: 0x132219,
|
|
7
|
+
raise: 0x1a2e21,
|
|
8
|
+
line: 0x24402e,
|
|
9
|
+
lineHi: 0x3dbe55,
|
|
10
|
+
sel: 0x1c3a26,
|
|
11
|
+
selHi: 0x24502f,
|
|
12
|
+
text: 0xe4eee6,
|
|
13
|
+
sub: 0xa9bfae,
|
|
14
|
+
dim: 0x6f8a77,
|
|
15
|
+
mute: 0x41594a,
|
|
16
|
+
green: 0x3dbe55,
|
|
17
|
+
greenD: 0x1f7a32,
|
|
18
|
+
greenDD: 0x123f1d,
|
|
19
|
+
lime: 0xb5e853,
|
|
20
|
+
mint: 0x7ee0a1,
|
|
21
|
+
orange: 0xff9f43,
|
|
22
|
+
red: 0xff5d5d,
|
|
23
|
+
redD: 0x5a1e1e,
|
|
24
|
+
yellow: 0xffd166,
|
|
25
|
+
blue: 0x6cb4ff,
|
|
26
|
+
violet: 0xb39cff,
|
|
27
|
+
pink: 0xff7ab6,
|
|
28
|
+
white: 0xffffff,
|
|
29
|
+
black: 0x000000,
|
|
30
|
+
header: 0x0e2a17,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function mix(a, b, t) {
|
|
34
|
+
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
|
|
35
|
+
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
|
|
36
|
+
return (Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg - ag) * t) << 8) | Math.round(ab + (bb - ab) * t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Gradient through a set of color stops, t ∈ [0, 1]
|
|
40
|
+
export function ramp(stops, t) {
|
|
41
|
+
t = Math.max(0, Math.min(1, t));
|
|
42
|
+
const k = t * (stops.length - 1);
|
|
43
|
+
const i = Math.min(stops.length - 2, Math.floor(k));
|
|
44
|
+
return mix(stops[i], stops[i + 1], k - i);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const BRAND = [0x1f7a32, 0x3dbe55, 0xb5e853, 0xfff27a, 0xb5e853, 0x3dbe55, 0x1f7a32];
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Text input, scrollable list, popup menu, and rich text wrapping with highlighting.
|
|
2
|
+
|
|
3
|
+
import { T } from "./theme.js";
|
|
4
|
+
import { strWidth, truncate, charWidth } from "../term/width.js";
|
|
5
|
+
|
|
6
|
+
export class TextInput {
|
|
7
|
+
constructor({ placeholder = "", value = "", onChange, onSubmit, filter } = {}) {
|
|
8
|
+
this.value = value;
|
|
9
|
+
this.cursor = [...value].length;
|
|
10
|
+
this.placeholder = placeholder;
|
|
11
|
+
this.onChange = onChange;
|
|
12
|
+
this.onSubmit = onSubmit;
|
|
13
|
+
this.filter = filter; // (ch) → bool: which characters to accept
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(v) { this.value = v; this.cursor = [...v].length; }
|
|
17
|
+
|
|
18
|
+
key(k) {
|
|
19
|
+
const chars = [...this.value];
|
|
20
|
+
let changed = false;
|
|
21
|
+
if (k.type === "paste") {
|
|
22
|
+
const add = [...k.text.replace(/[\r\n\t]+/g, " ")].filter((c) => !this.filter || this.filter(c));
|
|
23
|
+
chars.splice(this.cursor, 0, ...add); this.cursor += add.length; changed = true;
|
|
24
|
+
} else if (k.name === "enter") { this.onSubmit?.(this.value); return true; }
|
|
25
|
+
else if (k.name === "backspace") { if (this.cursor > 0) { chars.splice(this.cursor - 1, 1); this.cursor--; changed = true; } else return true; }
|
|
26
|
+
else if (k.name === "delete" || (k.ctrl && k.name === "d")) { if (this.cursor < chars.length) { chars.splice(this.cursor, 1); changed = true; } else return true; }
|
|
27
|
+
else if (k.name === "left" && !k.alt) { this.cursor = Math.max(0, this.cursor - 1); return true; }
|
|
28
|
+
else if (k.name === "right" && !k.alt) { this.cursor = Math.min(chars.length, this.cursor + 1); return true; }
|
|
29
|
+
else if (k.name === "home" || (k.ctrl && k.name === "a")) { this.cursor = 0; return true; }
|
|
30
|
+
else if (k.name === "end" || (k.ctrl && k.name === "e")) { this.cursor = chars.length; return true; }
|
|
31
|
+
else if (k.ctrl && k.name === "u") { chars.splice(0, this.cursor); this.cursor = 0; changed = true; }
|
|
32
|
+
else if (k.ctrl && k.name === "k") { chars.splice(this.cursor); changed = true; }
|
|
33
|
+
else if ((k.ctrl && k.name === "w") || (k.alt && k.name === "backspace")) {
|
|
34
|
+
let i = this.cursor;
|
|
35
|
+
while (i > 0 && chars[i - 1] === " ") i--;
|
|
36
|
+
while (i > 0 && chars[i - 1] !== " ") i--;
|
|
37
|
+
chars.splice(i, this.cursor - i); this.cursor = i; changed = true;
|
|
38
|
+
} else if (k.ch && !k.ctrl && !k.alt) {
|
|
39
|
+
if (this.filter && !this.filter(k.ch)) return true;
|
|
40
|
+
chars.splice(this.cursor, 0, k.ch); this.cursor++; changed = true;
|
|
41
|
+
} else return false;
|
|
42
|
+
if (changed) { this.value = chars.join(""); this.onChange?.(this.value); }
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
draw(g, x, y, w, { focused = true, fg = T.text, bg, phFg = T.dim, blink = true } = {}) {
|
|
47
|
+
const chars = [...this.value];
|
|
48
|
+
if (!chars.length && !focused) { g.text(x, y, truncate(this.placeholder, w), { fg: phFg, bg, italic: true }); return; }
|
|
49
|
+
// Scroll so the cursor stays visible
|
|
50
|
+
let start = 0;
|
|
51
|
+
while (strWidth(chars.slice(start, this.cursor).join("")) > w - 1) start++;
|
|
52
|
+
let cx = x;
|
|
53
|
+
for (let i = start; i < chars.length; i++) {
|
|
54
|
+
const cw = charWidth(chars[i].codePointAt(0));
|
|
55
|
+
if (cx + cw > x + w) break;
|
|
56
|
+
const isCur = focused && i === this.cursor;
|
|
57
|
+
g.text(cx, y, chars[i], isCur ? { fg: T.bg, bg: T.lime } : { fg, bg });
|
|
58
|
+
cx += cw;
|
|
59
|
+
}
|
|
60
|
+
if (!chars.length) g.text(x + 1, y, truncate(this.placeholder, w - 1), { fg: phFg, bg, italic: true });
|
|
61
|
+
if (focused && this.cursor >= chars.length && blink) g.text(Math.min(cx, x + w - 1), y, " ", { bg: T.lime });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ListView {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.sel = 0;
|
|
68
|
+
this.top = 0;
|
|
69
|
+
this.height = 10;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clamp(n) {
|
|
73
|
+
if (n === 0) { this.sel = 0; this.top = 0; return; }
|
|
74
|
+
this.sel = Math.max(0, Math.min(n - 1, this.sel));
|
|
75
|
+
if (this.sel < this.top) this.top = this.sel;
|
|
76
|
+
if (this.sel >= this.top + this.height) this.top = this.sel - this.height + 1;
|
|
77
|
+
this.top = Math.max(0, Math.min(this.top, Math.max(0, n - this.height)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
key(k, n) {
|
|
81
|
+
switch (k.name) {
|
|
82
|
+
case "up": this.sel--; break;
|
|
83
|
+
case "down": this.sel++; break;
|
|
84
|
+
case "pageup": this.sel -= this.height - 1; break;
|
|
85
|
+
case "pagedown": this.sel += this.height - 1; break;
|
|
86
|
+
case "home": this.sel = 0; break;
|
|
87
|
+
case "end": this.sel = n - 1; break;
|
|
88
|
+
default:
|
|
89
|
+
if (k.hot === "k" && !k.ctrl) { this.sel--; break; }
|
|
90
|
+
if (k.hot === "j" && !k.ctrl) { this.sel++; break; }
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
this.clamp(n);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
wheel(dir, n) {
|
|
98
|
+
this.top = Math.max(0, Math.min(Math.max(0, n - this.height), this.top + dir * 3));
|
|
99
|
+
if (this.sel < this.top) this.sel = this.top;
|
|
100
|
+
if (this.sel >= this.top + this.height) this.sel = this.top + this.height - 1;
|
|
101
|
+
this.clamp(n);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// rowH is the row height; row(item, index, y, selected)
|
|
105
|
+
draw(g, app, x, y, w, h, items, row, { rowH = 1, onPick, scrollbar = true } = {}) {
|
|
106
|
+
this.height = Math.max(1, Math.floor(h / rowH));
|
|
107
|
+
this.clamp(items.length);
|
|
108
|
+
const end = Math.min(items.length, this.top + this.height);
|
|
109
|
+
for (let i = this.top; i < end; i++) {
|
|
110
|
+
const ry = y + (i - this.top) * rowH;
|
|
111
|
+
row(items[i], i, ry, i === this.sel);
|
|
112
|
+
app?.hit(x, ry, w, rowH, {
|
|
113
|
+
click: (ev) => { const again = this.sel === i; this.sel = i; this.clamp(items.length); if (again || ev.double) onPick?.(items[i], i); },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
app?.hit(x, y, w, h, { wheel: (dir) => this.wheel(dir, items.length), passive: true });
|
|
117
|
+
if (scrollbar) g.scrollbar(x + w, y, h, this.top, this.height, items.length);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Popup menu: single choice or checkboxes, with a filter field when there are many items
|
|
122
|
+
export class Menu {
|
|
123
|
+
constructor({ title, items, multi = false, onPick, onClose, x, y, width, filter }) {
|
|
124
|
+
this.title = title;
|
|
125
|
+
this.items = items; // [{ label, value, checked, hint, color, section }]
|
|
126
|
+
this.multi = multi;
|
|
127
|
+
this.onPick = onPick;
|
|
128
|
+
this.onClose = onClose;
|
|
129
|
+
this.list = new ListView();
|
|
130
|
+
this.anchor = x !== undefined ? { x, y } : null;
|
|
131
|
+
this.width = width;
|
|
132
|
+
this.useFilter = filter ?? items.length > 12;
|
|
133
|
+
this.input = new TextInput({ placeholder: "фильтр…", onChange: () => { this.list.sel = 0; } });
|
|
134
|
+
const first = items.findIndex((i) => i.checked && !multi);
|
|
135
|
+
if (first > 0) this.list.sel = first;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get visible() {
|
|
139
|
+
const q = this.input.value.trim().toLowerCase();
|
|
140
|
+
return q ? this.items.filter((i) => i.label.toLowerCase().includes(q)) : this.items;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
key(k, app) {
|
|
144
|
+
const vis = this.visible;
|
|
145
|
+
if (k.name === "escape") { app.closeOverlay(this); this.onClose?.(); return true; }
|
|
146
|
+
if (k.name === "enter") {
|
|
147
|
+
const it = vis[this.list.sel];
|
|
148
|
+
if (!it) return true;
|
|
149
|
+
if (this.multi) { it.checked = !it.checked; this.onPick?.(it, this.items); }
|
|
150
|
+
else { app.closeOverlay(this); this.onPick?.(it); }
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
if (k.name === "space" && this.multi) {
|
|
154
|
+
const it = vis[this.list.sel];
|
|
155
|
+
if (it) { it.checked = !it.checked; this.onPick?.(it, this.items); }
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (["up", "down", "pageup", "pagedown", "home", "end"].includes(k.name)) { this.list.key(k, vis.length); return true; }
|
|
159
|
+
if (this.useFilter && this.input.key(k)) return true;
|
|
160
|
+
if (!this.useFilter && (k.hot === "j" || k.hot === "k")) { this.list.key(k, vis.length); return true; }
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
draw(g, app) {
|
|
165
|
+
const vis = this.visible;
|
|
166
|
+
const labelW = Math.max(strWidth(this.title ?? "") + 4, ...this.items.map((i) => strWidth(i.label) + (i.hint ? strWidth(i.hint) + 3 : 0) + (this.multi ? 4 : 2)));
|
|
167
|
+
const w = Math.min(g.w - 4, Math.max(this.width ?? 0, labelW + 4, 30));
|
|
168
|
+
const rows = Math.min(vis.length, Math.max(3, g.h - 10), 18);
|
|
169
|
+
const h = rows + 2 + (this.useFilter ? 2 : 0) + (this.multi ? 1 : 0);
|
|
170
|
+
let x = this.anchor ? Math.min(this.anchor.x, g.w - w - 1) : Math.floor((g.w - w) / 2);
|
|
171
|
+
let y = this.anchor ? Math.min(this.anchor.y, g.h - h - 1) : Math.max(1, Math.floor((g.h - h) / 2));
|
|
172
|
+
x = Math.max(0, x); y = Math.max(0, y);
|
|
173
|
+
shadow(g, x, y, w, h);
|
|
174
|
+
g.box(x, y, w, h, { fg: T.lineHi, bg: T.panel2, title: this.title, titleFg: T.lime });
|
|
175
|
+
app.hit(x, y, w, h, { click: () => {}, wheel: (d) => this.list.wheel(d, vis.length) });
|
|
176
|
+
let ly = y + 1;
|
|
177
|
+
if (this.useFilter) {
|
|
178
|
+
g.text(x + 2, ly, "⌕", { fg: T.lime });
|
|
179
|
+
this.input.draw(g, x + 4, ly, w - 6, { bg: T.panel2 });
|
|
180
|
+
g.hline(x + 1, ly + 1, w - 2, T.line);
|
|
181
|
+
ly += 2;
|
|
182
|
+
}
|
|
183
|
+
this.list.draw(g, app, x + 1, ly, w - 3, rows, vis, (it, i, ry, sel) => {
|
|
184
|
+
const bg = sel ? T.selHi : T.panel2;
|
|
185
|
+
g.fill(x + 1, ry, w - 3, 1, bg);
|
|
186
|
+
let cx = x + 2;
|
|
187
|
+
if (it.section) { g.text(cx, ry, it.label, { fg: T.dim, bg, bold: true }); return; }
|
|
188
|
+
if (this.multi) cx = g.text(cx, ry, it.checked ? "◼ " : "◻ ", { fg: it.checked ? T.lime : T.dim, bg });
|
|
189
|
+
else cx = g.text(cx, ry, it.checked ? "● " : sel ? "▸ " : " ", { fg: T.lime, bg });
|
|
190
|
+
g.textW(cx, ry, it.label, w - 6 - (cx - x) - (it.hint ? strWidth(it.hint) + 1 : 0), { fg: it.color ?? (sel ? T.white : T.text), bg, bold: sel });
|
|
191
|
+
if (it.hint) g.textR(x + w - 3, ry, it.hint, { fg: T.dim, bg });
|
|
192
|
+
}, {
|
|
193
|
+
onPick: (it) => {
|
|
194
|
+
if (this.multi) { it.checked = !it.checked; this.onPick?.(it, this.items); }
|
|
195
|
+
else { app.closeOverlay(this); this.onPick?.(it); }
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (!vis.length) g.textC(x, w, ly, "ничего не найдено", { fg: T.dim, bg: T.panel2 });
|
|
199
|
+
if (this.multi) g.textC(x, w, y + h - 1, " пробел — отметить · esc — готово ", { fg: T.dim, bg: T.panel2 });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function shadow(g, x, y, w, h) {
|
|
204
|
+
g.tint(x + 1, y + h, w, 1, 0x050906);
|
|
205
|
+
g.tint(x + w, y + 1, 1, h, 0x050906);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Text wrapping with per-character coloring: colorAt(i) → style or null
|
|
209
|
+
export function richWrap(text, w, colorAt) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
const words = [];
|
|
212
|
+
const re = /\S+/g;
|
|
213
|
+
let m;
|
|
214
|
+
while ((m = re.exec(text))) words.push({ s: m[0], i: m.index });
|
|
215
|
+
let line = [], lw = 0;
|
|
216
|
+
for (const word of words) {
|
|
217
|
+
const ww = strWidth(word.s);
|
|
218
|
+
if (lw && lw + 1 + ww > w) { lines.push(line); line = []; lw = 0; }
|
|
219
|
+
line.push(word);
|
|
220
|
+
lw += (lw ? 1 : 0) + ww;
|
|
221
|
+
}
|
|
222
|
+
if (line.length) lines.push(line);
|
|
223
|
+
return lines.map((ws) => ws.map((wd) => ({ ...wd, st: colorAt(wd.i, wd.s) })));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function drawRich(g, x, y, w, maxLines, lines, base) {
|
|
227
|
+
const n = Math.min(lines.length, maxLines);
|
|
228
|
+
for (let j = 0; j < n; j++) {
|
|
229
|
+
let cx = x;
|
|
230
|
+
for (const wd of lines[j]) {
|
|
231
|
+
if (cx > x) cx = g.text(cx, y + j, " ", base);
|
|
232
|
+
cx = g.text(cx, y + j, truncate(wd.s, x + w - cx), wd.st ?? base);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return n;
|
|
236
|
+
}
|