Repository navigation
Expand file tree
/
Copy pathpanel-state.js
More file actions
619 lines (571 loc) · 37.5 KB
/
Copy pathpanel-state.js
File metadata and controls
619 lines (571 loc) · 37.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
'use strict';
/**
* Четыре состояния панели управления (редизайн 2026-08-12).
*
* Это ПРОТОТИПНЫЙ ПРИМЕСЬ-модуль, как custom-sounds.js и local-background.js:
* методы обращаются к this.startBtn / this.remainingSeconds и вызывают друг
* друга, поэтому свободными функциями их сделать нельзя без переписывания
* семантики this. Подключение — import и одна строка в control-app.js:
*
* Object.assign(TimerController.prototype, PanelStateMixin);
*
* Потерянную строку ловит tests/control-mixins.test.js (каждая
* импортированная примесь присвоена): без неё при загрузке НИЧЕГО не упало
* бы — упал бы первый же тик таймера. Ровно так в этом проекте уже терялись
* примеси.
*
* Что модуль знает о вёрстке: класс `state-*` на <body> и горстку id. Всё
* остальное — видимость блоков, цвета, порядок — решает CSS по этому классу.
* Модуль отдаёт ЗНАЧЕНИЯ и состояние, а не раскладку.
*/
import * as RendererShared from './renderer-shared.js';
/**
* Подзаголовок строки окна: «показан · круг · 140%».
*
* Чистая функция — принимает уже собранные значения и возвращает строку.
* Отделена от DOM намеренно: собрать текст и достать значения — две разные
* задачи, и ошибаться они будут по-разному.
*
* @param {{open: boolean, idle: string, style?: string, scale?: number, where?: string}} p
* @returns {string}
*/
function windowRowSubtitle(p) {
// Закрытое окно описывает СЕБЯ, открытое — своё состояние.
if (!p || !p.open) { return (p && p.idle) || ''; }
const parts = ['показан'];
if (p.style) { parts.push(p.style); }
// 100% — это «как обычно», и в подписи оно только шумит. Показываем
// масштаб, только когда он о чём-то говорит.
if (Number.isFinite(p.scale) && p.scale !== 100) { parts.push(p.scale + '%'); }
if (p.where) { parts.push(p.where); }
return parts.join(' · ');
}
/**
* Подзаголовок строки «Звуки»: какие сигналы прозвучат.
*
* Отчёт, а не реклама. До 03.11.2026 здесь стоял текст разметки «сигналы
* старта, минуты и финиша» — при умолчании, где включено одно окончание, а
* «минута» означает сигнал ЗА минуту до конца. Слова — подписи строк ящика.
*
* @param {{master: boolean, start: boolean, minute: boolean, end: boolean, overrun: boolean}} s
* @returns {string}
*/
function soundRowSubtitle(s) {
if (!s) { return ''; }
if (!s.master) { return 'звук выключен'; }
// Порядок — порядок событий таймера, а не строк ящика.
const parts = [
s.start && 'старт',
s.minute && 'за минуту',
s.end && 'окончание',
s.overrun && 'перерасход'
].filter(Boolean);
return parts.length ? parts.join(' · ') : 'сигналы не выбраны';
}
/**
* Подсказка в подвале панели — то, что пробел сделает СЕЙЧАС; в перерасходе —
* как часто прозвучит уведомление.
*
* `overrunEvery` — интервал в минутах, только если уведомление действительно
* прозвучит (включены и мастер, и звук перерасхода), иначе null. До
* 03.11.2026 подвал обещал «Уведомление каждые 1 мин» при выключенном звуке
* перерасхода — а это умолчание.
*
* @param {{mode: string, paused: boolean, overrunEvery: number|null}} p
* @returns {string}
*/
function panelFooterText({ mode, paused, overrunEvery }) {
if (paused) { return 'Space — продолжить · R — сброс'; }
if (mode === 'overtime') {
if (!Number.isFinite(overrunEvery) || overrunEvery <= 0) {
// Кнопка транспорта в перерасходе называется «Стоп», пробел жмёт её.
return 'Space — стоп · R — сброс';
}
if (overrunEvery === 1) { return 'Уведомление каждую минуту'; }
const one = overrunEvery % 10 === 1 && overrunEvery % 100 !== 11;
return `Уведомление ${one ? 'каждую' : 'каждые'} ${overrunEvery} мин`;
}
return mode === 'running' ? 'Space — пауза · R — сброс' : 'Space — старт · R — сброс · 1–4 — пресеты';
}
/** Человеческие имена стилей — те же слова, что на кнопках выбора стиля. */
const STYLE_LABELS = {
circle: 'круг',
digital: 'LED',
flip: 'флип',
analog: 'аналог',
digits: 'цифры'
};
const PanelStateMixin = {
/**
* Единственная сборка payload канала `widget-style-update`.
*
* Она была написана ШЕСТЬ раз подряд — в обработчиках стиля, шрифта,
* масштаба, делений и в отложенном пуше. Копии уже разошлись: две из шести
* не клали `timerScale` вообще, то есть виджет получал стиль без размера.
* Добавить сюда новое поле значило бы отредактировать шесть мест и забыть
* одно — ровно та поломка, которую этот проект уже ловил на настройках.
*
* @param {Partial<WidgetStyleUpdate>} [extra] поля, которые вызывающий
* знает точнее (например новое значение из обработчика change, ещё не
* записанное в контрол)
* @returns {WidgetStyleUpdate} тип — types/ipc-payloads.d.ts
*/
widgetStylePayload(extra) {
/** @type {WidgetStyleUpdate} */
const payload = {
timerStyle: this.timerStyleEl.value,
timerScale: parseInt(this.timerScaleEl.value, 10),
digitsFont: this.widgetDigitsFontEl ? this.widgetDigitsFontEl.value : 'inter',
// Подпись состояния («идёт / пауза / перерасход») по умолчанию
// ВЫКЛЮЧЕНА: её работу делает цвет дуги, а на маленьком виджете
// слово занимает место, которого нет.
statusLabel: this.widgetStatusLabelEl?.checked ?? false,
alwaysOnTop: this.widgetAlwaysOnTopEl?.checked ?? true
};
return Object.assign(payload, extra || {});
},
/**
* Стиль виджета и часов — окнам, БЕЗ задержки. Запуск панели зовёт его
* отложенно (control-app.js, конструктор), пресет — сразу (applyProfile в
* panel-presets.js): раньше обе посылки сидели в loadSettings под
* setTimeout, и пресет менял настройки окон мгновенно, а стиль — через
* полсекунды.
*/
pushWindowStyles() {
this.sendClockStyle();
window.ipcRenderer.send('widget-style-update', this.widgetStylePayload());
},
/**
* Раскладывает живое состояние окон в подзаголовки их строк.
*
* Значения берутся из САМИХ контролов настроек, а не из отдельной копии:
* копия разошлась бы с ящиком ровно в тот момент, когда пользователь
* что-то в нём поменял.
*
* Вызывается из двух мест и оба обязательны: renderPanelState() (состояние
* таймера меняет и окраску строк) и saveExtSettings() (настройка окна
* изменилась). Второй вызов появился потому, что первого не хватало:
* тик таймера в покое НЕ ПРИХОДИТ, и после смены стиля строка часов
* продолжала утверждать «показан · круг» про часы, уже ставшие флипом, —
* до первого запуска отсчёта. Один вызов на запись настроек, а не пять
* по обработчикам: пятая копия проводки в этом проекте уже теряла поле.
*/
renderWindowRows() {
const val = (id) => {
const el = document.getElementById(id);
if (!el) { return undefined; }
return el.value !== undefined ? el.value : el.dataset.value;
};
const num = (id) => {
const n = parseInt(val(id), 10);
return Number.isFinite(n) ? n : undefined;
};
const styleOf = (id) => STYLE_LABELS[val(id)];
const monitor = document.getElementById('displaySelect');
const where = monitor && monitor.value !== 'auto'
? monitor.options[monitor.selectedIndex]?.text.replace(/\s*\(.*\)$/, '')
: undefined;
// Стиль часов — ДЕЙСТВУЮЩИЙ, а не тот, что лежит в их переключателе.
// При включённой синхронизации часам уходит стиль виджета, а
// собственный выбор часов намеренно не переписывается (выключил
// синхронизацию — вернулся к своему). Строка выбора при этом скрыта,
// и подпись остаётся ЕДИНСТВЕННЫМ местом, где виден стиль часов:
// читая скрытый переключатель, она сообщала «круг» про часы, которые
// в этот момент аналоговые.
const clockStyleId = this.syncClockStyle ? 'timerStyle' : 'clockStyle';
const ROWS = [
{ btn: 'openWidgetBtn', sub: 'subWidget', style: styleOf('timerStyle'), scale: num('timerScale') },
{ btn: 'openClockBtn', sub: 'subClock', style: styleOf(clockStyleId), scale: num('clockScale') },
// Дисплей сообщал ТОЛЬКО монитор, хотя стиль и масштаб у него свои
// и настраиваются в том же ящике, что у виджета и часов. Строка
// окна — отчёт о состоянии окна; сокращённый отчёт у одной строки
// из трёх читается как «у дисплея этих настроек нет».
{
btn: 'openDisplayBtn', sub: 'subDisplay',
style: styleOf('displayTimerStyle'), scale: num('displayTimerScale'), where
}
];
for (const row of ROWS) {
const btn = document.getElementById(row.btn);
const sub = document.getElementById(row.sub);
if (!btn || !sub) { continue; }
if (!sub.dataset.idle) { sub.dataset.idle = sub.textContent; }
sub.textContent = windowRowSubtitle({
open: btn.classList.contains('active'),
idle: sub.dataset.idle,
style: row.style,
scale: row.scale,
where: row.where
});
}
// Строка «Звуки» — в том же списке и такой же отчёт о настройках.
this.renderSoundRow();
},
/**
* Проводка ручного ввода: поле, три входа в режим и мастер-тумблер звука.
*
* Живёт здесь, а не в панели, по той же причине, что и сами состояния:
* это ОДНА тема — «как панель принимает своё время». Разрезать её между
* двумя файлами значило бы чинить один сценарий в двух местах.
*/
bindPanelInputs() {
const ipcRenderer = window.ipcRenderer;
// Пятая ячейка ряда — «мин», у неё НЕТ data-minutes: она не
// длительность, а вход в ручной ввод. Без этого условия
// parseInt(undefined) дал бы NaN и команду set с NaN секунд.
document.querySelectorAll('.preset[data-minutes]').forEach(btn => {
btn.addEventListener('click', () => {
const minutes = parseInt(btn.dataset.minutes);
this.sendCommand('set', { seconds: minutes * 60 });
});
});
// Кнопки корректировки времени (±1 мин, ±5 мин)
document.querySelectorAll('.adjust-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.adjustTime(parseInt(btn.dataset.adjust));
});
});
// ВВОД ВРЕМЕНИ: три поля вместо одной слепленной строки.
//
// Разбирать формат больше не нужно — каждое поле принимает просто
// число, и «1:30:00» перестало быть тем, что надо помнить и во что
// надо попадать курсором.
//
// Таблица, а не три копии обработчика: поля отличаются только
// потолком и соседями, и написанные по отдельности они разошлись бы
// на первой же правке — как уже разошлись шесть копий payload.
const TIME_FIELDS = [
{ id: 'manualHours', max: 99, mult: 3600 },
{ id: 'manualMinutes', max: 59, mult: 60 },
{ id: 'manualSeconds', max: 59, mult: 1 }
];
const fields = TIME_FIELDS
.map((f) => Object.assign({}, f, { el: document.getElementById(f.id) }))
.filter((f) => f.el);
this._timeFields = fields;
const flashError = (el) => {
el.classList.add('input-error');
setTimeout(() => el.classList.remove('input-error'), 300);
el.focus();
el.select();
};
const applyManualTime = () => {
let total = 0;
for (const f of fields) {
const n = parseInt(f.el.value, 10) || 0;
// Значение выше потолка — опечатка, а не «ещё одна единица
// старшего разряда»: молча перенести 75 секунд в минуты значило
// бы поставить не то время, которое набрано. Часы потолка по
// смыслу не имеют, но 99 хватает с запасом на любой доклад.
if (n > f.max) { flashError(f.el); return; }
total += n * f.mult;
}
// Ноль — не время. Прежний парсер его тоже не принимал.
if (total <= 0) { flashError(fields[0].el); return; }
ipcRenderer.send('timer-command', { type: 'set', seconds: total });
this.setInputMode(false);
};
// Кнопка «Поставить» живёт в attachEvents() — это ДРУГАЯ область
// видимости, локальная const оттуда не видна. Кладём на объект.
this._applyManualTime = applyManualTime;
fields.forEach((f, i) => {
const prev = fields[i - 1];
const next = fields[i + 1];
// Цифры и ничего больше: поле числовое, буквам тут делать нечего.
f.el.addEventListener('input', () => {
const clean = f.el.value.replace(/\D/g, '').slice(0, 2);
if (clean !== f.el.value) { f.el.value = clean; }
});
// Выделяем по фокусу: первая же цифра заменяет значение целиком,
// а не дописывается к предзаполненному.
f.el.addEventListener('focus', () => f.el.select());
f.el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyManualTime();
return;
}
// Esc выходит из режима ввода и НЕ всплывает выше: Escape в этом
// окне слоёный, и без остановки он закрыл бы заодно ящик настроек.
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.setInputMode(false);
return;
}
// Двоеточие — привычный разделитель: набравший «1:30:00» подряд
// попадёт туда же, куда и нажавший Tab.
if ((e.key === ':' || e.key === 'ArrowRight') && next) {
e.preventDefault();
next.el.focus();
}
if (e.key === 'ArrowLeft' && prev && f.el.selectionStart === 0) {
e.preventDefault();
prev.el.focus();
}
// Backspace в пустом поле возвращает к предыдущему — иначе
// стирание упирается в невидимую стену.
if (e.key === 'Backspace' && prev && f.el.value === '') {
e.preventDefault();
prev.el.focus();
}
});
});
// Три входа в ручной ввод — клик по времени, ячейка «мин» и
// синяя кнопка «Поставить» (она же #startBtn в этом режиме).
document.getElementById('controlTime')?.addEventListener('click', () => this.setInputMode(true));
document.getElementById('presetCustom')?.addEventListener('click', () => this.setInputMode(true));
document.getElementById('manualCancel')?.addEventListener('click', () => this.setInputMode(false));
// Мастер-тумблер звука живёт строкой «Звуки» в списке окон. Сам он
// ничего не решает и не хранит — зовёт общий путь (см. ниже).
const soundMaster = document.getElementById('soundMasterToggle');
soundMaster?.addEventListener('click', () => this.toggleSoundMaster());
this.renderSoundRow();
// Клавиша Z, нажатая НЕ в панели: окно шлёт просьбу, переключает панель.
// Тот же приём, что у пресетов вида (канал `preset-apply`): состояние
// звука принадлежит панели, и второй дороги до него нет.
if (ipcRenderer) {
ipcRenderer.on('sound-toggle', () => this.toggleSoundMaster());
}
},
/**
* Мастер-звук: ЕДИНСТВЕННЫЙ путь переключения.
*
* Входов три — клик по тумблеру строки, клавиша `Z` в панели и та же
* клавиша, нажатая в виджете, часах или на дисплее. Владелец значения при
* этом один: чекбокс `#soundMasterEnabled`, потому что его сохраняет
* таблица настроек (settings-schema.js), а тумблер строки — только вид.
*
* До 24.08.2026 владельцев было ДВА: тумблер строки писал свой
* `soundEnabled` в localStorage и красил сам себя, чекбокс жил отдельно, а
* `playSound` спрашивал ОБА. Отсюда состояние «строка зелёная, звука нет».
* Событие `change` шлётся настоящее: на нём висит вся остальная работа
* (гашение настроек звука, флаг `soundEnabled`, сохранение).
*/
toggleSoundMaster() {
const box = this.soundMasterEl || document.getElementById('soundMasterEnabled');
if (!box) { return; }
box.checked = !box.checked;
box.dispatchEvent(new Event('change', { bubbles: true }));
this.renderSoundRow();
},
/**
* Вид строки «Звуки» — ВЫВОД из чекбоксов, а не второй флаг: тумблер
* повторяет мастер, подпись перечисляет включённые сигналы. Зовётся и из
* renderWindowRows, то есть на каждой записи настроек.
*/
renderSoundRow() {
const box = this.soundMasterEl || document.getElementById('soundMasterEnabled');
const row = document.getElementById('soundMasterToggle');
if (!box || !row) { return; }
row.setAttribute('aria-checked', String(!!box.checked));
row.classList.toggle('active', !!box.checked);
row.title = box.checked ? 'Звук включён' : 'Звук выключен';
const sub = document.getElementById('subSound');
if (sub) {
const on = (id) => !!/** @type {HTMLInputElement|null} */ (document.getElementById(id))?.checked;
sub.textContent = soundRowSubtitle({
master: !!box.checked,
start: on('soundStartEnabled'),
minute: on('soundMinuteEnabled'),
end: on('soundEndEnabled'),
overrun: on('soundOverrunEnabled')
});
}
},
/**
* Ручной ввод — ЧЕТВЁРТОЕ состояние панели, а не поле сбоку.
* Флаг держится здесь, а вся раскладка выводится из него в
* renderPanelState(): иначе состояние пришлось бы чинить в двух местах.
*/
setInputMode(on) {
// Во время отсчёта вводить нечего: там работают ± и пауза.
if (on && (this.isRunning || this.isPaused)) { return; }
this.inputMode = !!on;
const fields = this._timeFields || [];
if (this.inputMode) {
// Предзаполняем ТЕКУЩИМ временем, а не пустотой: чаще всего правят
// его, а не набирают с нуля. Поле выделяется по фокусу, поэтому
// первая же цифра заменяет значение целиком.
const total = Math.max(0, Math.round(this.remainingSeconds || this.presetSeconds || 0));
const parts = RendererShared.breakdown(total);
const value = { manualHours: parts.hours, manualMinutes: parts.minutes, manualSeconds: parts.seconds };
for (const f of fields) {
f.el.value = String(value[f.id]).padStart(f.id === 'manualHours' ? 1 : 2, '0');
}
// focus() после смены класса: пока поле display:none, фокус на нём
// не удерживается. Начинаем с МИНУТ — часы почти всегда нули, и
// начинать с них значило бы заставлять пропускать их каждый раз.
const first = fields.find((f) => f.id === 'manualMinutes') || fields[0];
requestAnimationFrame(() => first?.el.focus());
} else {
for (const f of fields) { f.el.blur(); }
}
this.renderPanelState();
},
/**
* Одна точка, которая раскладывает состояние таймера в панель:
* класс на <body> (он же управляет видимостью блоков через CSS),
* подпись над цифрами, подсказку под ними, полосу и футер.
*
* Состояние выводится из таймера, а не запоминается по последнему
* клику: любой другой способ изменить таймер — горячая клавиша,
* команда из другого окна, восстановление после падения — обязан
* приводить панель в тот же вид.
*/
renderPanelState(status, band) {
// Замечание про ПЕРВЫЙ КАДР: класс state-idle стоит прямо в разметке
// <body>. До прихода первого timer-state этот метод ещё не вызывался,
// класса состояния нет вообще — и правила видимости не находят ни одной
// кнопки транспорта: панель показывалась без «Старта». Дефект живёт
// ровно одну секунду и потому незаметен в разработке.
const isOvertime = (band || RendererShared.timerColorBand(
this.remainingSeconds, this.totalSeconds)) === 'overtime';
const live = this.isRunning || this.isPaused;
// Отсчёт пришёл, пока открыт ручной ввод (старт из виджета, клавиша
// другого окна): вводить во время отсчёта нечего — setInputMode и
// включить его не даёт. Без этой ветки панель показывала поля и
// «Поставить» при бегущем таймере, а Enter слал отклоняемый set.
if (this.inputMode && live) {
this.inputMode = false;
for (const f of this._timeFields || []) { f.el.blur(); }
}
const mode = this.inputMode ? 'input'
: isOvertime ? 'overtime'
: live ? 'running' : 'idle';
document.body.classList.remove('state-idle', 'state-running', 'state-overtime', 'state-input');
document.body.classList.add('state-' + mode);
// Пауза — МОДИФИКАТОР состояния, а не пятое состояние.
//
// Раскладка в паузе та же, что в отсчёте (ряд ±, полоса, никаких
// пресетов), меняется ровно одно: какое действие предлагает транспорт.
// Пока пауза жила внутри `state-running`, на экране оставалась кнопка
// «Пауза» — единственное действие, которое в паузе не делает ничего, —
// и возобновить таймер мышью было нечем ни в панели, ни в полосе.
// Отдельным классом это чинится в CSS, без второй лестницы состояний.
const paused = !this.inputMode && !!this.isPaused;
document.body.classList.toggle('paused', paused);
// Подписи строк пересобираются здесь же, а не только при открытии окна:
// стиль и масштаб меняются в ящике, и подпись обязана догонять их без
// отдельной проводки от каждого контрола.
this.renderWindowRows();
// СЛОВО берётся из общего владельца приоритетов, а не из режима
// раскладки. Первая версия писала слово по режиму и завела вторую
// лестницу приоритетов: в перерасходе она печатала «Перерасход»,
// затирая паузу, — а правило этого проекта обратное, пауза важнее
// всего (докладчик, выбившийся из времени и нажавший паузу, видел
// «Завершено» ровно из-за такой же второй лестницы).
// Режим решает РАСКЛАДКУ, статус решает НАДПИСЬ.
const LIFECYCLE_LABEL = {
paused: 'Пауза',
overtime: 'Перерасход',
finished: 'Завершено',
running: 'Осталось',
idle: 'Длительность'
};
const lifecycle = status || RendererShared.timerLifecycleStatus({
remainingSeconds: this.remainingSeconds,
totalSeconds: this.totalSeconds,
isRunning: this.isRunning,
isPaused: this.isPaused,
finished: this.wasFinished
});
const label = mode === 'input' ? 'Своё время' : LIFECYCLE_LABEL[lifecycle];
if (this.statusText) { this.statusText.textContent = label; }
// Подсказка под цифрами. В отсчёте и перерасходе это ВРЕМЯ
// ОКОНЧАНИЯ — оно отвечает на вопрос, который иначе считают в
// уме: «во сколько это кончится».
//
// `window.__frozenEndsAt` — крючок стенда съёмки
// (scripts/screenshot-runner.js, freezeWallClock): кадр панели не
// должен зависеть от минуты съёмки. До модулей стенд подменял
// `window.RendererShared.endsAt`, но import-у подмена не видна
// (namespace только для чтения), поэтому значение читается здесь, на
// настоящем пути подписи. Приложение его не ставит никогда.
const frozenEndsAt = window.__frozenEndsAt;
const endLabel = typeof frozenEndsAt === 'string'
? frozenEndsAt
: RendererShared.endsAt(this.remainingSeconds, new Date());
const HINT = {
idle: 'нажмите на время, чтобы ввести своё',
running: endLabel ? `закончится в ${endLabel}` : '',
overtime: endLabel ? `должно было закончиться в ${endLabel}` : '',
input: 'часы · минуты · секунды — Enter поставит'
};
const hintEl = document.getElementById('heroHint');
if (hintEl) { hintEl.textContent = HINT[mode]; }
// Полоса. Панель отдаёт ДОЛЮ переменной, ширину считает CSS. В
// перерасходе заливку прибивает к 100 % правило состояния — и оно
// обязано выигрывать. Пока здесь писался инлайн `style.width`, он
// перебивал это правило: в минус, попавший скачком («−5»,
// восстановление), полоса оставалась на последней доле отсчёта.
const fill = document.getElementById('panelProgressFill');
if (fill && mode === 'running') {
const total = this.totalSeconds || 0;
const done = total > 0 ? (total - this.remainingSeconds) / total : 0;
fill.style.setProperty('--progress', String(Math.max(0, Math.min(1, done))));
}
// Подсказка внизу называет то действие, которое пробел сделает СЕЙЧАС:
// в паузе он продолжает отсчёт, а не ставит паузу ещё раз.
const footer = document.getElementById('panelFooter');
if (footer) {
// Те же два условия, что у playSound: мастер и звук перерасхода.
const sounds = !!this.soundMasterEl?.checked && !!this.soundOverrunEl?.checked;
const every = parseInt(this.overrunIntervalEl?.value, 10);
footer.textContent = panelFooterText({ mode, paused, overrunEvery: sounds ? every : null });
}
// Кнопка одна, названий у неё четыре: пауза добавила «Продолжить».
if (this.startBtn) {
this.startBtn.firstChild.nodeValue = this.inputMode ? 'Поставить' : (paused ? 'Продолжить' : 'Старт');
const key = this.startBtn.querySelector('.transport-key');
if (key) { key.textContent = this.inputMode ? 'Enter' : 'Space'; }
}
if (this.pauseBtn) {
this.pauseBtn.firstChild.nodeValue = isOvertime ? 'Стоп' : 'Пауза';
}
},
// Ряды настроек, применимых не ко всем стилям. Настройка, которая
// ничего не делает при выбранном стиле, — это обещание без
// исполнения: тумблер щёлкает, на экране не меняется ничего.
updateStyleDependentRows() {
const widgetStyle = this.timerStyleEl.value;
// Действующий стиль часов — одна формула, panel-clock.js.
const clockStyle = this.effectiveClockStyle();
const rows = [
[this.widgetDigitsFontRowEl, widgetStyle],
[this.clockDigitsFontRowEl, clockStyle],
[this.displayDigitsFontRowEl, this.displayTimerStyleEl ? this.displayTimerStyleEl.value : 'circle']
];
for (const [row, style] of rows) {
if (row) { row.style.display = style === 'digits' ? 'flex' : 'none'; }
}
// Подпись состояния живёт ВНУТРИ круга (.circular-widget), у
// остальных стилей виджета её в разметке нет вовсе.
const statusRow = document.getElementById('widgetStatusRow');
if (statusRow) {
statusRow.style.display = widgetStyle === 'circle' ? 'flex' : 'none';
}
// Деления — настройка ЧАСОВ: круглый циферблат есть только у их стиля
// «Круг». У виджета таймера делений больше нет вовсе — там это кольцо
// обратного отсчёта, и засечки на нём только шумели.
const ticksRow = document.getElementById('clockTicksRow');
if (ticksRow) {
ticksRow.style.display = clockStyle === 'circle' ? 'flex' : 'none';
}
// Прозрачность фона — не для флипа: карточки перекидыша читаются как
// физические, и просвечивающая карточка выглядит поломкой, а не
// настройкой. Сам ЦВЕТ фона у флипа остаётся.
const alphaRows = [
['widgetAlphaLine', widgetStyle],
['clockAlphaLine', clockStyle]
];
for (const [id, style] of alphaRows) {
const row = document.getElementById(id);
if (row) { row.style.display = style === 'flip' ? 'none' : 'flex'; }
}
}
};
// Примесь — отдельным именем, как у остальных: прежний module.exports БЫЛ
// самой примесью и дописывал в неё windowRowSubtitle (в окне его у объекта
// не было, в Node — был; формы расходились).
export { PanelStateMixin, windowRowSubtitle, soundRowSubtitle, panelFooterText };