Zum Inhalt

src/settings/main.ts

  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
// Native settings dialog (Windows) — renders the full v0.10 option set (looks,
// CRT sliders, motion/story, monitors, performance) and posts it back to the
// Win32 host via window.chrome.webview.postMessage ("save:<query>" or "cancel";
// host answers "saveerror" when it rejects). Loads standalone in a browser too
// (styling / dev loop); without the host bridge the messages go to the console.
//
// v0.11: two tabs, two fully independent option sets. One DOM drives both — the
// tab switch repoints `state` and re-runs the refreshers, so every control keeps
// exactly one definition and the two tabs cannot drift apart.
//
// Scene / preset / look lists come straight from the engine data — the retired
// C# ConfigForm duplicated them by hand.

import { SCENES } from '../engine/data/defaults';
import { LOOKS } from '../engine/data/looks';
import { PRESETS } from '../engine/data/presets';
import {
  ENUM_VALUES,
  MONITOR_CAP,
  NUMERIC_RANGES,
  buildSaveMessage,
  initialMonitorStates,
  readAutostart,
  readInitial,
  readMonitors,
  readTab,
  readWallpaperInitial,
  readWallpaperPaused,
  readWallpaperRunning,
  type BoolKey,
  type MonitorEntry,
  type MonitorFormState,
  type NumericKey,
  type SettingsFormState,
  type Tab,
} from './form-state';

const params = new URLSearchParams(location.search);
const saverState = readInitial(params);
const wallpaperState = readWallpaperInitial(params);
const monitors = readMonitors(params);

let tab: Tab = readTab(params);
const isWallpaperTab = (): boolean => tab === 'wallpaper';

// Every control below closes over this binding rather than a fixed object, so
// repointing it IS the tab switch.
let state: SettingsFormState = isWallpaperTab() ? wallpaperState : saverState;

// MonitorMode travels as its own query param (not part of the 33-key format);
// echoed back verbatim in the save message when non-empty. One per tab: the
// wallpaper's span/per choice lives in WallpaperMonitorMode and has always been
// independent of the saver's — v0.10 just had no UI for it.
const monitorModes: Record<Tab, string> = {
  saver: params.get('monitormode') ?? '',
  wallpaper: params.get('wpmonitormode') ?? '',
};
if (monitors.length >= 2) {
  for (const t of ['saver', 'wallpaper'] as const) {
    if (monitorModes[t] !== 'per' && monitorModes[t] !== 'span') monitorModes[t] = 'per';
  }
}

let autostart = readAutostart(params);

// Per-monitor overrides start from the existing config the host embeds in
// the monitors=-JSON — an untouched save keeps them: the saver trio round-trips
// verbatim, the wallpaper trio is not sent at all until a card is touched in the
// wallpaper tab (see monitorCard / buildSaveMessage).
const monitorStates: MonitorFormState[] = initialMonitorStates(monitors);

// Every control registers a refresher so state mutations from elsewhere
// (look bundles, dependent enable/visibility rules) reflect in the DOM.
const refreshers: Array<() => void> = [];
function refreshAll(): void {
  for (const r of refreshers) r();
}

function post(message: string): void {
  const bridge = (window as unknown as {
    chrome?: { webview?: { postMessage(m: string): void } };
  }).chrome?.webview;
  if (bridge) bridge.postMessage(message);
  else console.log('[settings] no host bridge —', message);
}

function row(labelText: string, control: HTMLElement): HTMLElement {
  const div = document.createElement('div');
  div.className = 'row';
  const label = document.createElement('label');
  label.textContent = labelText;
  div.append(label, control);
  return div;
}

interface Option { value: string; label?: string }
const opts = (values: readonly string[]): Option[] => values.map((value) => ({ value }));

function select(options: readonly Option[], get: () => string, set: (v: string) => void): HTMLSelectElement {
  const sel = document.createElement('select');
  for (const o of options) {
    const opt = document.createElement('option');
    opt.value = o.value;
    opt.textContent = o.label ?? o.value;
    sel.append(opt);
  }
  // Normalise unknown values to a real option — on load AND on every refresh,
  // because a tab switch shows a set this select has never seen. This is the
  // last line of defence, not the first: only the tab on screen has selects, so
  // registry garbage is rejected in readInitial (form-state.ts), which sees both
  // halves. Both tabs travel in one all-or-nothing save message.
  const normalise = (): void => {
    const current = get();
    sel.value = options.some((o) => o.value === current) ? current : options[0].value;
    if (sel.value !== current) set(sel.value);
  };
  normalise();
  sel.addEventListener('change', () => set(sel.value));
  refreshers.push(normalise);
  return sel;
}

function toggle(key: BoolKey, labelText: string): HTMLElement {
  const wrap = document.createElement('label');
  wrap.className = 'toggle';
  const box = document.createElement('input');
  box.type = 'checkbox';
  box.checked = state[key];
  box.addEventListener('change', () => {
    state[key] = box.checked;
    refreshAll();
  });
  refreshers.push(() => { box.checked = state[key]; });
  wrap.append(box, document.createTextNode(labelText));
  return wrap;
}

// Sliders keep the raw input string in state (byte-parity contract); the
// registry value is only replaced once the user actually drags. Range
// attributes come from the shared NUMERIC_RANGES table (form-state.ts) so the
// UI can never diverge from readInitial's validation or the host's ranges.
function slider(
  labelText: string, key: NumericKey,
  o: { resetsLook?: boolean; fmt?: (v: string) => string } = {},
): HTMLElement {
  const { min, max, step } = NUMERIC_RANGES[key];
  const fmt = o.fmt ?? ((v: string) => v);
  const div = document.createElement('div');
  div.className = 'row slider';
  const label = document.createElement('label');
  label.textContent = labelText;
  const input = document.createElement('input');
  input.type = 'range';
  input.min = String(min);
  input.max = String(max);
  input.step = String(step);
  input.value = state[key];
  const val = document.createElement('span');
  val.className = 'val';
  val.textContent = fmt(state[key]);
  input.addEventListener('input', () => {
    state[key] = input.value;
    if (o.resetsLook) state.look = ''; // manual tweak leaves the Look bundle
    val.textContent = fmt(input.value);
    refreshAll();
  });
  refreshers.push(() => {
    input.value = state[key];
    val.textContent = fmt(state[key]);
  });
  div.append(label, input, val);
  return div;
}

function section(titleText: string): { root: HTMLElement; grid: HTMLElement } {
  const root = document.createElement('section');
  root.className = 'section';
  const h = document.createElement('h2');
  h.textContent = titleText;
  const grid = document.createElement('div');
  grid.className = 'grid';
  root.append(h, grid);
  return { root, grid };
}

// ---------------------------------------------------------------- Look row
// Picking a look materialises its values into the slider fields (same mapping
// as applyLook in looks.ts); any manual slider change flips back to "Eigene".
function applyLookValues(key: string): void {
  const L = LOOKS[key];
  if (!L) return;
  state.preset = L.preset;
  state.matrix = L.matrix;
  // Materialise the on-flags exactly like applyLook (looks.ts) — otherwise the
  // saver renders the Look differently from web/macOS (e.g. Heavy CRT without
  // trails, Clean still glitching because crt stays on at intensity 0).
  state.crt = L.intensity > 0.001;
  state.trails = L.trails > 0.001;
  state.bloom = true;
  state.crtintensity = String(L.intensity);
  state.curvature = String(L.curvature);
  state.aperture = String(L.aperture);
  state.ntsc = String(L.ntsc);
  state.halation = String(L.halation);
  state.bloomstrength = String(L.bloom);
  state.trailsamount = String(0.5 + L.trails * 0.45); // 0..0.9 → damp 0.5..0.95
}

const lookSelect = select(
  [{ value: '', label: 'Eigene' }, ...Object.entries(LOOKS).map(([value, L]) => ({ value, label: L.label }))],
  () => state.look,
  (v) => { state.look = v; },
);
// Side effects only on USER change — never on load, so untouched registry
// strings survive the roundtrip byte-identically.
lookSelect.addEventListener('change', () => {
  applyLookValues(lookSelect.value);
  refreshAll();
});
const lookRow = row('Look', lookSelect);
lookRow.classList.add('look-row');

// ---------------------------------------------------------------- Sektionen
const bild = section('Bild');
bild.grid.append(
  row('Scene', select(opts(['random', ...SCENES]), () => state.scene, (v) => (state.scene = v))),
  row('Color', select(opts(Object.keys(PRESETS)), () => state.preset, (v) => (state.preset = v))),
  // Option lists come from the shared ENUM_VALUES table (form-state.ts), so the
  // UI can never offer a value readInitial's guard — or the host — rejects.
  row('Speed', select(opts(ENUM_VALUES.speed), () => state.speed, (v) => (state.speed = v))),
  row('Altitude', select(opts(ENUM_VALUES.altitude), () => state.altitude, (v) => (state.altitude = v))),
  row('Fog', select(opts(ENUM_VALUES.fog), () => state.fog, (v) => (state.fog = v))),
  row('Weather', select(opts(ENUM_VALUES.weather), () => state.weather, (v) => (state.weather = v))),
);

const crtFx = section('CRT-Effekte');
crtFx.grid.append(
  slider('Intensity', 'crtintensity', { resetsLook: true }),
  slider('Curvature', 'curvature', { resetsLook: true }),
  slider('Aperture', 'aperture', { resetsLook: true }),
  slider('Bloom', 'bloomstrength', { resetsLook: true }),
  slider('Trails', 'trailsamount', { resetsLook: true }),
  slider('NTSC', 'ntsc', { resetsLook: true }),
  slider('Halation', 'halation', { resetsLook: true }),
  toggle('scan', 'Scanlines'),
  toggle('crt', 'CRT simulation'),
  toggle('bloom', 'Bloom'),
  toggle('trails', 'Afterburn'),
);

const motion = section('Bewegung & Story');
motion.grid.append(
  slider('Bank', 'bank'),
  toggle('reactive', 'Reactive world'),
  toggle('autocycle', 'Auto-cycle'),
  slider('Cycle (min)', 'cyclemin'),
  toggle('terminal', 'Story terminal'),
);

const term = section('Terminal');
const termlayoutSelect = select(
  opts(ENUM_VALUES.termlayout), () => state.termlayout, (v) => (state.termlayout = v),
);
refreshers.push(() => { termlayoutSelect.disabled = !state.terminal; });
termlayoutSelect.disabled = !state.terminal;
term.grid.append(row('Layout', termlayoutSelect));

const display = section('Anzeige & Automatik');
display.grid.append(
  toggle('radar', 'Radar'),
  toggle('crosshair', 'Crosshair'),
  toggle('matrix', 'Matrix rain'),
  toggle('boot', 'Boot sequence'),
  row('Boot speed', select(opts(ENUM_VALUES.bootspeed), () => state.bootspeed, (v) => (state.bootspeed = v))),
  toggle('daynight', 'Day/night'),
);

// Audio is forced off in the wallpaper (BuildWallpaperPage) — greyed out WITH
// the reason, not hidden: a missing switch reads as a bug, a greyed one with an
// explanation reads as a decision (spec §5).
const audioToggle = toggle('audio', 'Audio');
const audioBox = audioToggle.querySelector('input')!;
const audioReason = document.createElement('span');
audioReason.className = 'reason';
audioReason.textContent = 'aus — im Wallpaper immer';
audioToggle.append(audioReason);
refreshers.push(() => {
  audioBox.disabled = isWallpaperTab();
  audioToggle.classList.toggle('disabled', isWallpaperTab());
  audioReason.hidden = !isWallpaperTab();
});
display.grid.append(audioToggle);

// ---------------------------------------------------------------- Monitore
function badge(text: string): HTMLElement {
  const b = document.createElement('span');
  b.className = 'badge';
  b.textContent = text;
  return b;
}

// One card per monitor, serving BOTH tabs: the accessors below read and write
// the saver's trio or the wallpaper's depending on the active tab, exactly like
// `state` does for the 33 globals. The wallpaper's trio has lived in the
// registry since v0.10 and was reachable from no UI at all until now.
function monitorCard(entry: MonitorEntry, ms: MonitorFormState): HTMLElement {
  const card = document.createElement('div');
  card.className = 'mon-card';
  const head = document.createElement('div');
  head.className = 'mon-head';
  const name = document.createElement('span');
  name.className = 'mon-name';
  name.textContent = `${entry.name || entry.id} — ${entry.w}×${entry.h}`;
  head.append(name);
  if (entry.primary) head.append(badge('Hauptmonitor'));
  if (entry.portrait) head.append(badge('Hochformat'));

  const getMode = (): string => (isWallpaperTab() ? ms.wmode : ms.mode);
  const setMode = (v: string): void => { if (isWallpaperTab()) ms.wmode = v; else ms.mode = v; };
  const getScene = (): string => (isWallpaperTab() ? ms.wscene : ms.scene);
  const setScene = (v: string): void => { if (isWallpaperTab()) ms.wscene = v; else ms.scene = v; };
  const getPreset = (): string => (isWallpaperTab() ? ms.wpreset : ms.preset);
  const setPreset = (v: string): void => { if (isWallpaperTab()) ms.wpreset = v; else ms.preset = v; };

  // "The user decided something about this monitor's wallpaper" — the flag the
  // save reads to know whether the trio may be written at all (form-state.ts).
  // Only a real `change` marks it: the selects also call their setter from
  // normalise() on every refresh, and a tab switch normalising a card the user
  // never looked at must not count as a decision.
  const markWallpaperTouched = (): void => { if (isWallpaperTab()) ms.wtouched = true; };

  const modeSelect = select(opts(['on', 'off', 'random', 'scene']), getMode, setMode);
  modeSelect.addEventListener('change', markWallpaperTouched);
  modeSelect.addEventListener('change', refreshAll);
  const sceneSelect = select([{ value: '', label: 'global' }, ...opts(SCENES)], getScene, setScene);
  sceneSelect.addEventListener('change', markWallpaperTouched);
  const sceneRow = row('Scene', sceneSelect);
  sceneRow.hidden = getMode() !== 'scene';
  refreshers.push(() => { sceneRow.hidden = getMode() !== 'scene'; });
  const presetSelect = select(
    [{ value: '', label: 'global' }, ...opts(Object.keys(PRESETS))], getPreset, setPreset,
  );
  presetSelect.addEventListener('change', markWallpaperTouched);
  card.append(head, row('Mode', modeSelect), sceneRow, row('Color', presetSelect));
  return card;
}

let monitorSection: HTMLElement | null = null;
if (monitors.length > 0) {
  const mon = section('Monitore');
  monitorSection = mon.root;
  if (monitors.length >= 2) {
    const radios = document.createElement('div');
    radios.className = 'row radios';
    const label = document.createElement('label');
    label.textContent = 'Modus';
    radios.append(label);
    for (const [value, text] of [['per', 'Je Monitor'], ['span', 'Eine Fläche']] as const) {
      const wrap = document.createElement('label');
      wrap.className = 'toggle';
      const radio = document.createElement('input');
      radio.type = 'radio';
      radio.name = 'monitormode';
      radio.value = value;
      radio.checked = monitorModes[tab] === value;
      radio.addEventListener('change', () => {
        if (radio.checked) monitorModes[tab] = value;
        refreshAll();
      });
      refreshers.push(() => { radio.checked = monitorModes[tab] === value; });
      wrap.append(radio, document.createTextNode(text));
      radios.append(wrap);
    }
    radios.style.gridColumn = '1 / -1';
    mon.grid.append(radios);
  }
  // Save-format cap: ParseSaveMessage accepts m0..m7 only, buildSaveMessage
  // slices accordingly — so no card for what would silently not be saved.
  monitors.slice(0, MONITOR_CAP).forEach((entry, i) => {
    mon.grid.append(monitorCard(entry, monitorStates[i]));
  });
  if (monitors.length > MONITOR_CAP) {
    const capHint = document.createElement('div');
    capHint.className = 'hint';
    capHint.textContent = `Maximal ${MONITOR_CAP} Monitore konfigurierbar`;
    capHint.style.gridColumn = '1 / -1';
    mon.grid.append(capHint);
  }
  const hint = document.createElement('div');
  hint.className = 'hint';
  hint.textContent = 'Empfehlung: Render-Scale ≤ 66 % bei 3 Monitoren';
  refreshers.push(() => { hint.hidden = monitorModes[tab] !== 'span'; });
  mon.root.append(hint);
}

const perf = section('Leistung');
perf.grid.append(
  slider('Render scale', 'scale', {
    fmt: (v) => `${Math.round(parseFloat(v) * 100)} %`,
  }),
  toggle('perfadapt', 'Adaptive quality'),
);

// ------------------------------------------------- Tabs, Status, Autostart
const tabBar = document.createElement('div');
tabBar.className = 'tabs';
for (const [value, text] of [['saver', 'Screensaver'], ['wallpaper', 'Wallpaper']] as const) {
  const btn = document.createElement('button');
  btn.className = 'tab';
  btn.textContent = text;
  btn.addEventListener('click', () => {
    tab = value;
    state = value === 'wallpaper' ? wallpaperState : saverState;
    refreshAll();
  });
  refreshers.push(() => { btn.classList.toggle('active', tab === value); });
  tabBar.append(btn);
}

// Status and autostart live in the wallpaper tab only: they concern the
// wallpaper alone — the screensaver is started by Windows itself (spec E4).
const status = document.createElement('div');
status.className = 'status';
const running = readWallpaperRunning(params);
status.textContent = running ? `● Läuft auf ${running} Monitoren` : '○ Wallpaper läuft nicht';
status.classList.toggle('on', running !== '');

// Only when something is actually running: the saver's /c dialog has no tray and
// no wallpaper behind it, so a button there would post into the void.
let paused = readWallpaperPaused(params);
const pauseBtn = document.createElement('button');
pauseBtn.className = 'pause';
pauseBtn.type = 'button';
const labelPause = (): void => { pauseBtn.textContent = paused ? 'Fortsetzen' : 'Anhalten'; };
labelPause();
// Immediate-apply, like the autostart box and the tray item it mirrors: pausing
// is an action, not a setting you stage and confirm with Save.
pauseBtn.addEventListener('click', () => {
  paused = !paused;
  labelPause();
  post(`pause:${paused ? 'on' : 'off'}`);
});

const autostartRow = document.createElement('label');
autostartRow.className = 'toggle autostart';
const autostartBox = document.createElement('input');
autostartBox.type = 'checkbox';
autostartBox.checked = autostart;
// Applies immediately instead of on Save: the Run key is an OS setting, not one
// of the 33 options — same as the tray's toggle, which it mirrors.
autostartBox.addEventListener('change', () => {
  autostart = autostartBox.checked;
  post(`autostart:${autostart ? 'on' : 'off'}`);
});
autostartRow.append(autostartBox, document.createTextNode('Mit Windows starten'));

const statusRow = document.createElement('div');
statusRow.className = 'status-row';
statusRow.append(status);
if (running) statusRow.append(pauseBtn);

const wallpaperHead = document.createElement('div');
wallpaperHead.className = 'wp-head';
wallpaperHead.append(statusRow, autostartRow);
refreshers.push(() => { wallpaperHead.hidden = !isWallpaperTab(); });

// ---------------------------------------------------------------- Buttons
const errorLine = document.createElement('div');
errorLine.className = 'error';
errorLine.textContent = 'Speichern abgelehnt — Werte prüfen';
errorLine.hidden = true;

// Host rejects the save all-or-nothing → dialog stays open, show the error.
(window as unknown as {
  chrome?: { webview?: { addEventListener?: (t: string, h: (ev: { data: unknown }) => void) => void } };
}).chrome?.webview?.addEventListener?.('message', (ev) => {
  if (ev.data === 'saveerror') errorLine.hidden = false;
});

const buttons = document.createElement('div');
buttons.className = 'buttons';
const save = document.createElement('button');
save.textContent = 'Save';
save.addEventListener('click', () => {
  errorLine.hidden = true;
  // Both tabs always go out, whichever one is visible — the host splits them by
  // prefix into two registry keys and never read-modify-writes the other's
  // (spec §5.2). The saver's set is always `saverState`, never the active tab.
  post(buildSaveMessage(
    saverState, monitorStates, monitorModes.saver, wallpaperState, monitorModes.wallpaper,
  ));
});
const cancel = document.createElement('button');
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => post('cancel'));
buttons.append(save, cancel);

const title = document.createElement('h1');
title.textContent = 'KURO // SETTINGS'; // serves both tabs now

const app = document.getElementById('app')!;
app.append(title, tabBar, wallpaperHead, lookRow, bild.root, crtFx.root, motion.root, term.root, display.root);
if (monitorSection) app.append(monitorSection);
app.append(perf.root, errorLine, buttons);

// Nothing above rendered tab-dependent state yet — the refreshers own that, so
// the first paint is the same code path as every later tab switch.
refreshAll();