vkusvill 0.0.0-dev.0 → 0.0.0-dev.1

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.
@@ -2,7 +2,7 @@
2
2
  // and a recipe page with the steps and an ingredient checklist that goes into the cart with one key.
3
3
 
4
4
  import { T, mix } from "../ui/theme.js";
5
- import { TextInput, ListView, Menu } from "../ui/widgets.js";
5
+ import { TextInput, ListView, Menu, ChipBar } from "../ui/widgets.js";
6
6
  import { image } from "../image.js";
7
7
  import * as api from "../api.js";
8
8
  import { normalize, qtyStep, plural, decode } from "../product.js";
@@ -36,6 +36,7 @@ class RecipesView {
36
36
  this.input = new TextInput({ placeholder: "блюдо или продукт: сырники, тыква, курица…" });
37
37
  this.input.onChange = (v) => { clearTimeout(this.timer); this.timer = setTimeout(() => this.setQuery(v), 350); };
38
38
  this.list = new ListView();
39
+ this.bar = new ChipBar();
39
40
  this.q = "";
40
41
  this.f = loadRecipeFilters();
41
42
  this.filterDefs = [];
@@ -49,6 +50,22 @@ class RecipesView {
49
50
 
50
51
  saveFilters() { store.setPref("recipes", this.f); }
51
52
 
53
+ focusTop() { this.focus = "search"; }
54
+
55
+ chips() {
56
+ const act = this.activeFilters();
57
+ return [
58
+ { label: act.length ? "фильтры ▾" : "фильтры: аллергены, время, сложность…", prefix: "f ", run: () => this.filterMenu() },
59
+ ...act.map((a) => ({
60
+ label: `${a.label} ×`, fg: T.bg, bg: a.key === "allergens" ? T.orange : T.lime, bold: true,
61
+ run: () => {
62
+ if (a.key === "allergens") this.f.allergens = this.f.allergens.filter((i) => i !== a.id); else this.f[a.key] = 0;
63
+ this.saveFilters(); this.reset(); this.loadMore();
64
+ },
65
+ })),
66
+ ];
67
+ }
68
+
52
69
  tick() {
53
70
  if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.items.length - 4) this.loadMore();
54
71
  }
@@ -122,13 +139,20 @@ class RecipesView {
122
139
  key(k) {
123
140
  if (this.focus === "search") {
124
141
  if (k.type === "paste") return this.input.key(k);
125
- if (["down", "tab", "escape"].includes(k.name)) { this.focus = "list"; return true; }
142
+ if (k.name === "down" || k.name === "tab") { this.focus = "chips"; return true; }
143
+ if (k.name === "escape") { this.focus = "list"; return true; }
126
144
  if (k.name === "enter") { clearTimeout(this.timer); this.setQuery(this.input.value); this.focus = "list"; return true; }
127
145
  return this.input.key(k);
128
146
  }
129
147
  if (k.type !== "key") return false;
148
+ // Zones top to bottom: search → filter chips → list
149
+ if (this.focus === "chips") {
150
+ if (k.name === "up") { this.focus = "search"; return true; }
151
+ if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
152
+ if (this.bar.key(k, this.chips())) return true;
153
+ }
130
154
  const h = k.hot ?? k.name;
131
- if (k.name === "up" && this.list.sel === 0) { this.focus = "search"; return true; }
155
+ if (k.name === "up" && this.list.sel === 0) { this.focus = "chips"; return true; }
132
156
  if (this.list.key(k, this.items.length)) return true;
133
157
  if (h === "/" || k.ch === ".") { this.focus = "search"; return true; }
134
158
  if (h === "f") { this.filterMenu(); return true; }
@@ -139,38 +163,21 @@ class RecipesView {
139
163
  }
140
164
 
141
165
  hints() {
142
- if (this.focus === "search") return [["⏎", "искать"], ["↓", "к списку"]];
166
+ if (this.focus === "search") return [["⏎", "искать"], ["↓", "к фильтрам"]];
167
+ if (this.focus === "chips") return [["← →", "выбор"], ["⏎", "открыть или снять"], ["↑", "к поиску"], ["↓", "к списку"]];
143
168
  return [["↑↓", "выбор"], ["⏎", "рецепт"], ["/", "поиск"], ["f", "фильтры"], ["o", "на сайте"], ["esc", "сбросить"]];
144
169
  }
145
170
 
146
171
  draw(g, r) {
147
172
  const app = this.app;
148
173
  g.fill(r.x, r.y, r.w, r.h, T.bg);
149
- const focused = this.focus === "search";
150
- g.box(r.x, r.y, r.w, 3, { fg: focused ? T.lineHi : T.line, bg: T.panel, title: "Рецепты ВкусВилла", titleFg: focused ? T.lime : T.dim, right: `${this.total ?? "…"} ${plural(this.total ?? 0, "рецепт", "рецепта", "рецептов")}` });
174
+ const focused = this.focus === "search" && !app.headerFocus;
175
+ g.box(r.x, r.y, r.w, 3, { fg: focused ? T.lineHi : T.line, bg: T.panel, title: "Поиск рецептов", titleFg: focused ? T.lime : T.dim, right: `${this.total ?? "…"} ${plural(this.total ?? 0, "рецепт", "рецепта", "рецептов")}` });
151
176
  g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
152
177
  this.input.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused, bg: T.panel, blink: app.frame % 10 < 7 || !focused });
153
178
  app.hit(r.x, r.y, r.w, 3, { click: () => { this.focus = "search"; } });
154
- // Active filters
155
- let x = r.x + 1;
156
- const fy = r.y + 3;
157
- x = g.text(x, fy, "f ", { fg: T.dim });
158
- const act = this.activeFilters();
159
- if (!act.length) {
160
- const label = "фильтры: аллергены, время, сложность…";
161
- g.pill(x, fy, label, { fg: T.sub, bg: T.raise });
162
- app.hit(x, fy, strWidth(label) + 2, 1, { click: () => this.filterMenu() });
163
- }
164
- for (const a of act) {
165
- const w = strWidth(a.label) + 4;
166
- if (x + w > r.x + r.w - 2) break;
167
- g.pill(x, fy, a.label + " ×", { fg: T.bg, bg: a.key === "allergens" ? T.orange : T.lime, bold: true });
168
- app.hit(x, fy, w, 1, { click: () => {
169
- if (a.key === "allergens") this.f.allergens = this.f.allergens.filter((i) => i !== a.id); else this.f[a.key] = 0;
170
- this.saveFilters(); this.reset(); this.loadMore();
171
- } });
172
- x += w + 1;
173
- }
179
+ // The filters button and the active filters (Enter on one removes it)
180
+ this.bar.draw(g, app, r.x + 1, r.y + 3, r.x + r.w - 2, this.chips(), { focused: this.focus === "chips" && !app.headerFocus });
174
181
  const top = r.y + 4;
175
182
  const H = r.h - 4;
176
183
  const paneW = r.w >= 96 ? Math.max(40, Math.min(60, Math.floor(r.w * 0.42))) : 0;
@@ -180,14 +187,31 @@ class RecipesView {
180
187
  g.textC(r.x, lw, top + Math.floor(H / 2) - 1, this.loading ? "Листаю кулинарную книгу…" : this.error ? `⚠ ${this.error}` : "Ничего не нашлось", { fg: this.error ? T.red : T.dim, bg: T.panel });
181
188
  if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
182
189
  } else {
190
+ // Wide enough: one line per recipe with the details in aligned columns on the right;
191
+ // otherwise the details go on a second line
192
+ const COLS = [["time", 14], ["level", 11], ["portions", 11], ["kcal", 17]];
193
+ const metaW = COLS.reduce((s, [, w]) => s + w, 0);
194
+ const oneLine = lw - 6 - metaW >= 28;
195
+ const rowH = oneLine ? 1 : 2;
183
196
  this.list.draw(g, app, r.x, top, lw - 1, H, this.items, (it, i, y, sel) => {
184
197
  const bg = sel ? (this.focus === "list" ? T.selHi : T.sel) : T.panel;
185
- g.fill(r.x, y, lw - 1, 2, bg);
186
- if (sel) { g.text(r.x, y, "▌", { fg: T.lime, bg }); g.text(r.x, y + 1, "▌", { fg: T.lime, bg }); }
198
+ g.fill(r.x, y, lw - 1, rowH, bg);
199
+ for (let j = 0; j < rowH; j++) if (sel) g.text(r.x, y + j, "▌", { fg: T.lime, bg });
200
+ const meta = {
201
+ time: it.cooking_time?.name && `◷ ${it.cooking_time.name}`,
202
+ level: it.complexity?.name && `◆ ${it.complexity.name.toLowerCase()}`,
203
+ portions: it.portions && `${it.portions} ${plural(it.portions, "порция", "порции", "порций")}`,
204
+ kcal: it.nutritional?.calories && `${Math.round(it.nutritional.calories)} ккал/100 г`,
205
+ };
206
+ if (oneLine) {
207
+ g.textW(r.x + 2, y, decode(it.name), lw - 6 - metaW, { fg: sel ? T.white : T.text, bold: sel, bg });
208
+ let cx = r.x + lw - 2 - metaW;
209
+ for (const [key, w] of COLS) { if (meta[key]) g.textW(cx, y, meta[key], w - 1, { fg: T.dim, bg }); cx += w; }
210
+ return;
211
+ }
187
212
  g.textW(r.x + 2, y, decode(it.name), lw - 6, { fg: sel ? T.white : T.text, bold: sel, bg });
188
- const meta = [it.cooking_time?.name && `◷ ${it.cooking_time.name}`, it.complexity?.name && `◆ ${it.complexity.name.toLowerCase()}`, it.portions && `${it.portions} ${plural(it.portions, "порция", "порции", "порций")}`, it.nutritional?.calories && `${Math.round(it.nutritional.calories)} ккал/100 г`].filter(Boolean).join(" ");
189
- g.textW(r.x + 2, y + 1, meta, lw - 6, { fg: T.dim, bg });
190
- }, { rowH: 2, onPick: (it) => this.open(it) });
213
+ g.textW(r.x + 2, y + 1, Object.values(meta).filter(Boolean).join(" "), lw - 6, { fg: T.dim, bg });
214
+ }, { rowH, onPick: (it) => this.open(it) });
191
215
  }
192
216
  if (paneW) this.drawPane(g, { x: r.x + lw + 1, y: top, w: paneW - 1, h: H }, this.items[this.list.sel]);
193
217
  }
@@ -269,8 +293,18 @@ class RecipeView {
269
293
  if (k.type !== "key") return false;
270
294
  const h = k.hot ?? k.name;
271
295
  if (this.narrow && this.showSteps && k.name === "escape") { this.showSteps = false; return true; }
272
- if (this.narrow && this.showSteps && (k.name === "up" || k.name === "down")) { this.scroll = Math.max(0, this.scroll + (k.name === "up" ? -1 : 1)); return true; }
273
- if (this.list.key(k, this.ings.length)) return true;
296
+ if (this.narrow && this.showSteps && (k.name === "up" || k.name === "down")) {
297
+ if (k.name === "up" && this.scroll <= 0) return false;
298
+ this.scroll = Math.max(0, this.scroll + (k.name === "up" ? -1 : 1));
299
+ return true;
300
+ }
301
+ // The "+ в корзину" button sits above the ingredients: ↑ from the first one reaches it
302
+ if (this.focus === "button") {
303
+ if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
304
+ if (k.name === "enter" || k.name === "space") { this.addChecked(); return true; }
305
+ if (k.name === "up") return false;
306
+ } else if (k.name === "up" && this.list.sel <= 0) { this.focus = "button"; return true; }
307
+ if (this.focus !== "button" && this.list.key(k, this.ings.length)) return true;
274
308
  const ing = this.ings[this.list.sel];
275
309
  if (k.name === "space" || h === "x") { if (ing) ing.checked = !ing.checked; return true; }
276
310
  if (h === "a") { const all = this.ings.every((i) => i.checked); this.ings.forEach((i) => { i.checked = !all && !!i.ids?.length; }); return true; }
@@ -283,7 +317,10 @@ class RecipeView {
283
317
  return false;
284
318
  }
285
319
 
286
- hints() { return [["esc", "назад"], ["␣", "отметить"], ["a", "все"], ["+", "в корзину"], ["⏎", "товар"], [this.narrow ? "s" : "PgDn", "шаги"], ["o", "на сайте"]]; }
320
+ hints() {
321
+ if (this.focus === "button") return [["⏎", "положить отмеченное в корзину"], ["↓", "к ингредиентам"], ["esc", "к ингредиентам"]];
322
+ return [["← esc", "назад"], ["␣", "отметить"], ["a", "все"], ["+", "в корзину"], ["⏎", "товар"], [this.narrow ? "s" : "PgDn", "шаги"], ["o", "на сайте"]];
323
+ }
287
324
 
288
325
  draw(g, r) {
289
326
  const app = this.app;
@@ -313,7 +350,9 @@ class RecipeView {
313
350
  const checked = this.ings.filter((i) => i.checked).length;
314
351
  g.text(x, y, `ИНГРЕДИЕНТЫ`, { fg: T.dim, bold: true, bg: T.panel });
315
352
  const btn = this.adding ? ` кладу ${this.adding.done}/${this.adding.total}… ` : ` + в корзину: ${checked} `;
316
- g.textR(x + w, y, btn, { fg: T.bg, bg: this.adding ? T.yellow : T.lime, bold: true });
353
+ const onBtn = this.focus === "button" && !app.headerFocus;
354
+ g.textR(x + w, y, btn, { fg: T.bg, bg: this.adding ? T.yellow : T.lime, bold: true, underline: onBtn });
355
+ if (onBtn) g.textR(x + w - strWidth(btn) - 1, y, "▸", { fg: T.lime, bg: T.panel });
317
356
  app.hit(x + w - strWidth(btn), y, strWidth(btn), 1, { click: () => this.addChecked() });
318
357
  y += 1;
319
358
  this.list.draw(g, app, r.x, y, lw - 1, r.y + r.h - y, this.ings, (ing, i, ry, sel) => {
@@ -2,7 +2,7 @@
2
2
  // and a Braille-dot map of all loaded shops with crosshairs on the selected one; z zooms in.
3
3
 
4
4
  import { T, mix } from "../ui/theme.js";
5
- import { ListView, Menu, TextInput } from "../ui/widgets.js";
5
+ import { ListView, Menu, TextInput, ChipBar } from "../ui/widgets.js";
6
6
  import { Braille } from "../ui/draw.js";
7
7
  import * as api from "../api.js";
8
8
  import { plural } from "../product.js";
@@ -75,6 +75,7 @@ class ShopsView {
75
75
  constructor(app) {
76
76
  this.app = app;
77
77
  this.list = new ListView();
78
+ this.bar = new ChipBar();
78
79
  // Place filters survive restarts; Moscow region is the default
79
80
  const saved = store.pref("shops", null);
80
81
  const isId = (v) => Number.isInteger(v) && v >= 0;
@@ -103,6 +104,18 @@ class ShopsView {
103
104
  return [...v].sort((a, b) => isCourier(a) - isCourier(b));
104
105
  }
105
106
 
107
+ focusTop() { this.focus = "search"; }
108
+
109
+ // Place filters as keyboard-walkable pills, and "all on the map" while pages remain
110
+ chips() {
111
+ const out = [["region", "r"], ["city", "g"], ["subway", "m"], ["feature", "f"]].map(([kind, key]) => {
112
+ const val = this.f[kind] ? this.names[kind] : kind === "region" && this.regionHint ? this.regionHint : "любой";
113
+ return { label: `${key} ${FILTER_NAMES[kind]}: ${val} ▾`, run: () => this.pick(kind), ...(this.f[kind] ? { fg: T.bg, bg: T.lime, bold: true } : {}) };
114
+ });
115
+ if (this.hasMore && this.items.length) out.push({ label: "L все на карту", run: () => this.loadAll() });
116
+ return out;
117
+ }
118
+
106
119
  tick() {
107
120
  if (this.hasMore && !this.loading && !this.error && this.page && this.list.sel >= this.visible.length - 5) this.loadMore();
108
121
  }
@@ -223,11 +236,19 @@ class ShopsView {
223
236
  key(k) {
224
237
  if (this.focus === "search") {
225
238
  if (k.type === "paste") return this.search.key(k);
226
- if (["down", "enter", "tab", "escape"].includes(k.name)) { this.focus = "list"; if (k.name === "escape") this.search.set(""); return true; }
239
+ if (k.name === "down" || k.name === "tab") { this.focus = "chips"; return true; }
240
+ if (k.name === "enter" || k.name === "escape") { this.focus = "list"; if (k.name === "escape") this.search.set(""); return true; }
227
241
  return this.search.key(k);
228
242
  }
229
243
  if (k.type !== "key") return false;
244
+ // Zones top to bottom: address search → place filters → list
245
+ if (this.focus === "chips") {
246
+ if (k.name === "up") { this.focus = "search"; return true; }
247
+ if (k.name === "down" || k.name === "escape") { this.focus = "list"; return true; }
248
+ if (this.bar.key(k, this.chips())) return true;
249
+ }
230
250
  const vis = this.visible;
251
+ if (k.name === "up" && this.list.sel <= 0) { this.focus = "chips"; return true; }
231
252
  if (this.list.key(k, vis.length)) return true;
232
253
  const h = k.hot ?? k.name;
233
254
  const s = vis[this.list.sel];
@@ -247,48 +268,39 @@ class ShopsView {
247
268
  }
248
269
 
249
270
  hints() {
250
- if (this.focus === "search") return [["⏎ ↓", "к списку"], ["esc", "сбросить поиск"]];
271
+ if (this.focus === "search") return [["↓", "к фильтрам"], ["⏎", "к списку"], ["esc", "сбросить поиск"]];
272
+ if (this.focus === "chips") return [["← →", "выбор"], ["⏎", "выбрать"], ["↑", "к поиску"], ["↓", "к списку"]];
251
273
  return [["↑↓", "магазин"], ["/", "поиск"], ...(this.app.g.w < 100 ? [["v", "карта"]] : []), ["z", "приблизить"], ["r g m f", "регион·город·метро·особ."], ["L", "все на карту"], ["p", "Я.Карты"], ["o", "на сайте"], ["y", "адрес"]];
252
274
  }
253
275
 
254
276
  draw(g, r) {
255
277
  const app = this.app;
256
278
  g.fill(r.x, r.y, r.w, r.h, T.bg);
257
- // Filters
258
- let x = r.x + 1;
259
- for (const [kind, key] of [["region", "r"], ["city", "g"], ["subway", "m"], ["feature", "f"]]) {
260
- const val = this.f[kind] ? this.names[kind] : kind === "region" && this.regionHint ? this.regionHint : "любой";
261
- const label = `${key} ${FILTER_NAMES[kind]}: ${val} ▾`;
262
- const w = strWidth(label) + 2;
263
- if (x + w > r.x + r.w - 30) break;
264
- g.pill(x, r.y, label, { fg: this.f[kind] ? T.bg : T.sub, bg: this.f[kind] ? T.lime : T.raise, bold: !!this.f[kind] });
265
- app.hit(x, r.y, w, 1, { click: () => this.pick(kind) });
266
- x += w + 1;
267
- }
268
- // Loading progress
279
+ const vis = this.visible;
280
+ // Search box, like the catalog and recipes: a framed field with the "open now" count in its corner
281
+ const searching = this.focus === "search" && !app.headerFocus;
282
+ const open = vis.filter((s) => shopState(s)?.open).length;
283
+ const shops = vis.filter((s) => !isCourier(s)).length;
284
+ g.box(r.x, r.y, r.w, 3, {
285
+ fg: searching ? T.lineHi : T.line, bg: T.panel, title: "Поиск магазина", titleFg: searching ? T.lime : T.dim,
286
+ right: vis.length ? `● ${open} из ${shops} открыто` : "", rightFg: open ? T.lime : T.dim,
287
+ });
288
+ g.text(r.x + 2, r.y + 1, "⌕", { fg: T.lime, bold: true, bg: T.panel });
289
+ this.search.draw(g, r.x + 4, r.y + 1, r.w - 8, { focused: searching, bg: T.panel, blink: app.frame % 10 < 7 || !searching });
290
+ app.hit(r.x, r.y, r.w, 3, { click: () => { this.focus = "search"; } });
291
+ // Place filters and the loading progress under the box
292
+ const fy = r.y + 3;
293
+ this.bar.draw(g, app, r.x + 1, fy, r.x + r.w - 30, this.chips(), { focused: this.focus === "chips" && !app.headerFocus });
269
294
  const progress = this.pages ? this.page / this.pages : 0;
270
295
  const pw = 16;
271
296
  const px = r.x + r.w - pw - 12;
272
- g.gradBar(px, r.y, pw, progress, [T.greenD, T.lime]);
273
- g.text(px + pw + 1, r.y, `${this.items.length}/${this.total ?? "…"}`, { fg: T.dim });
274
- app.hit(px, r.y, pw + 10, 1, { click: () => this.loadAll() });
297
+ g.gradBar(px, fy, pw, progress, [T.greenD, T.lime]);
298
+ g.text(px + pw + 1, fy, `${this.items.length}/${this.total ?? "…"}`, { fg: T.dim });
299
+ app.hit(px, fy, pw + 10, 1, { click: () => this.loadAll() });
275
300
 
276
- const top = r.y + 2, H = r.h - 2;
301
+ const top = r.y + 5, H = r.h - 5;
277
302
  if (r.w < 100 && this.mapOnly) return this.drawMap(g, { x: r.x, y: top, w: r.w, h: H });
278
303
  const lw = r.w >= 100 ? Math.max(44, Math.floor(r.w * 0.42)) : r.w;
279
- const vis = this.visible;
280
- // Search line and the "open now" summary
281
- const sy = r.y + 1;
282
- const searching = this.focus === "search";
283
- g.fill(r.x, sy, lw, 1, searching ? T.raise : T.bg);
284
- g.text(r.x + 1, sy, "⌕", { fg: searching || this.search.value ? T.lime : T.dim, bg: searching ? T.raise : T.bg });
285
- this.search.draw(g, r.x + 3, sy, lw - 24, { focused: searching, bg: searching ? T.raise : T.bg, blink: app.frame % 10 < 7 });
286
- app.hit(r.x, sy, lw - 20, 1, { click: () => { this.focus = "search"; } });
287
- if (vis.length) {
288
- const open = vis.filter((s) => shopState(s)?.open).length;
289
- const shops = vis.filter((s) => !isCourier(s)).length;
290
- g.textR(r.x + lw - 1, sy, `● ${open} из ${shops} открыто`, { fg: open ? T.lime : T.red, bg: searching ? T.raise : T.bg });
291
- }
292
304
  g.fill(r.x, top, lw, H, T.panel);
293
305
  if (!vis.length) {
294
306
  const msg = this.loading ? "Ищу магазины…" : this.error ? `⚠ ${this.error}` : this.items.length ? `По «${this.search.value}» ничего` : "Магазинов не нашлось";
@@ -298,20 +310,35 @@ class ShopsView {
298
310
  else if (this.search.value && this.hasMore && !this.loading) g.textC(r.x, lw, top + Math.floor(H / 2) + 1, "L — загрузить все магазины и искать по ним", { fg: T.dim, bg: T.panel });
299
311
  if (this.loading) loader(g, r.x + Math.floor(lw / 2) - 6, top + Math.floor(H / 2) + 1, app.frame);
300
312
  } else {
313
+ // Wide enough (no map beside the list): one line per shop, place and hours in columns
314
+ const PLACE = 20, HOURS = 28, RATING = 7;
315
+ const oneLine = lw - 6 - PLACE - HOURS - RATING >= 26;
316
+ const rowH = oneLine ? 1 : 2;
301
317
  this.list.draw(g, app, r.x, top, lw - 1, H, vis, (s, i, y, sel) => {
302
- const bg = sel ? T.selHi : T.panel;
303
- g.fill(r.x, y, lw - 1, 2, bg);
304
- if (sel) { g.text(r.x, y, "▌", { fg: T.lime, bg }); g.text(r.x, y + 1, "▌", { fg: T.lime, bg }); }
318
+ const bg = sel ? (this.focus === "list" && !app.headerFocus ? T.selHi : T.sel) : T.panel;
319
+ g.fill(r.x, y, lw - 1, rowH, bg);
320
+ for (let j = 0; j < rowH; j++) if (sel) g.text(r.x, y + j, "▌", { fg: T.lime, bg });
305
321
  const st = shopState(s);
306
322
  const courier = st?.courier;
307
323
  g.text(r.x + 2, y, courier ? "◌" : st ? (st.open ? "●" : "○") : "·", { fg: courier ? T.mute : st?.open ? T.lime : T.red, bg });
308
324
  let ax = r.x + 4;
309
325
  if (courier) ax = g.text(ax, y, "склад ", { fg: T.violet, bg });
326
+ const place = s.subway?.name ? `Ⓜ ${s.subway.name}` : s.city?.name ?? "";
327
+ const hours = courier ? "только для курьеров" : st?.text ?? s.schedule ?? "";
328
+ const hoursFg = courier ? T.mute : st && !st.open ? mix(T.red, T.dim, 0.5) : T.dim;
329
+ const rating = s.rating && !courier ? `★ ${String(s.rating).replace(".", ",")}` : "";
330
+ if (oneLine) {
331
+ const rx = r.x + lw - 2 - PLACE - HOURS - RATING;
332
+ g.textW(ax, y, cleanAddress(s), rx - ax - 1, { fg: courier ? T.dim : sel ? T.white : T.text, bold: sel && !courier, bg });
333
+ g.textW(rx, y, place, PLACE - 1, { fg: T.dim, bg });
334
+ g.textW(rx + PLACE, y, hours, HOURS - 1, { fg: hoursFg, bg });
335
+ if (rating) g.textR(r.x + lw - 2, y, rating, { fg: T.yellow, bg });
336
+ return;
337
+ }
310
338
  g.textW(ax, y, cleanAddress(s), r.x + lw - 10 - ax, { fg: courier ? T.dim : sel ? T.white : T.text, bold: sel && !courier, bg });
311
- if (s.rating && !courier) g.textR(r.x + lw - 2, y, `★ ${String(s.rating).replace(".", ",")}`, { fg: T.yellow, bg });
312
- const sub = [s.subway?.name ? `Ⓜ ${s.subway.name}` : s.city?.name, courier ? "только для курьеров" : st?.text ?? s.schedule].filter(Boolean).join(" · ");
313
- g.textW(r.x + 4, y + 1, sub, lw - 6, { fg: courier ? T.mute : st && !st.open ? mix(T.red, T.dim, 0.5) : T.dim, bg });
314
- }, { rowH: 2, onPick: () => { this.zoom = !this.zoom; } });
339
+ if (rating) g.textR(r.x + lw - 2, y, rating, { fg: T.yellow, bg });
340
+ g.textW(r.x + 4, y + 1, [place, hours].filter(Boolean).join(" · "), lw - 6, { fg: hoursFg, bg });
341
+ }, { rowH, onPick: () => { this.zoom = !this.zoom; } });
315
342
  }
316
343
  if (lw < r.w) this.drawMap(g, { x: r.x + lw + 1, y: top, w: r.w - lw - 1, h: H });
317
344
  }