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.
@@ -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
+ }
@@ -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
+ }