AppSettings.textSize (compact|default|large) becomes uiScale: number, 0.5-4 in 0.1 steps, default 1. One shared clampUiScale (next to the effectiveConcurrency precedent) defines "valid" for the store, the service and the stepper: clamp to range, snap to the step grid, strip float noise, and map NaN/Infinity to 1. - SettingsStore.load migrates legacy files (compact/default/large -> 0.9/1/1.2, never over an explicit uiScale) and clamps ALWAYS, not just on migration — buildPreload writes this value into every window's root font-size, so a hand-edited settings.json (uiScale: 100) must not be able to render the UI unusable. Verified against 100, 0.001, -3, NaN, Infinity and float noise; migration verified against a real legacy file. - SettingsService: applyUiScale sets the root font-size (16 * scale, removed entirely at 1 per the delete-when-default rule). Rides the existing settingsChanged broadcast, so open editor/manager windows follow live. - buildPreload bakes the same font-size so the first paint is already scaled — without it every window opens at 100% and snaps. - Both tab-bars: the 3-button Interface row becomes the stepper. Clicking the percentage resets to 100% (tooltip says so). i18n: compact/default/large dropped, smaller/larger/reset added, all 7 locales. Verified live, win7 + modern: scale 1 is pixel-identical to the pre-work baseline (opaque interior diff exactly 0); 0.5x is a real dense mode (~2x rows); 2x scales title bar, tabs, rows, icons and controls together with text contained in its boxes — which is what stage 1c's 7.css bridge bought; 4x is enormous but coherent, and the win7 title-bar controls stay on-screen at the default window size. The known caveat stands: no minimum window size exists, so 4x in a small window is legal but cramped. Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
import { BrowserWindow } from "electrobun/bun";
|
|
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
|
|
|
/**
|
|
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
|
|
*
|
|
* Every window must be built from this, never from the live settings snapshot.
|
|
* The skin picks a whole component tree (bootstrapped from the preload-baked
|
|
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
|
|
* without a restart — but the persisted setting CAN change mid-session. Reading
|
|
* the live value would boot a newly opened editor or manager into the pending
|
|
* skin while the main window still runs the old one: two windows, two trees,
|
|
* two frames.
|
|
*
|
|
* Pinning it in one place makes that impossible rather than making each call
|
|
* site remember. Everything else in the preload keeps using the live snapshot,
|
|
* because theme/tone/accent/text-size genuinely do apply live.
|
|
*/
|
|
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
|
|
|
|
/** Call once, at startup, before any window is constructed. */
|
|
export function initSessionSkin(skin: AppSettings["skin"]): void {
|
|
sessionSkin = skin;
|
|
}
|
|
|
|
/**
|
|
* The preload globals every window gets. Single composition point: the UI
|
|
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
|
* root-component selection both depend on them).
|
|
*
|
|
* The persisted theme attributes are baked in here — mirroring the
|
|
* delete/set logic of SettingsService.applyX — so the FIRST paint is already
|
|
* themed (the inline <style> in ui/src/index.html consumes them). Without
|
|
* this, forced themes only apply after the async getSettings RPC and new
|
|
* windows flash the wrong background.
|
|
*/
|
|
export function buildPreload(
|
|
mode: "main" | "editor" | "manager",
|
|
settings: AppSettings,
|
|
ctx?: unknown,
|
|
): string {
|
|
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
|
// a window opened before the restart. See initSessionSkin.
|
|
let script =
|
|
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
|
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
|
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
|
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
|
|
|
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
|
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
|
// ONLY place it is ever written — the UI must never toggle it live, because
|
|
// each skin's CSS is written against its own tree's markup.
|
|
if (sessionSkin === "win7") {
|
|
script += `document.documentElement.classList.add("win7");`;
|
|
}
|
|
|
|
const dataset: Record<string, string> = {};
|
|
if (settings.theme !== "system") dataset["theme"] = settings.theme;
|
|
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
|
|
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
|
|
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
|
|
// is already scaled — without this every window opens at 100% and snaps.
|
|
// Written as an inline style, not a data attribute: the scale is continuous.
|
|
if (settings.uiScale !== 1) {
|
|
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
|
|
}
|
|
for (const [key, value] of Object.entries(dataset)) {
|
|
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
|
|
}
|
|
return script;
|
|
}
|
|
|
|
/**
|
|
* Native chrome options for a window. The ONE place the frame decision is made —
|
|
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
|
*
|
|
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
|
* re-decorate a live window — which is half of why the skin is a launch-time
|
|
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
|
* call site can accidentally frame a window from a pending setting.
|
|
*/
|
|
export function chromeOptions() {
|
|
const skin = sessionSkin;
|
|
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
|
|
// close), so the window is frameless on EVERY platform. `titleBarStyle:
|
|
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
|
|
// removes the macOS traffic lights — there is no separate
|
|
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
|
|
// gtk_window_set_decorated(false).
|
|
//
|
|
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
|
|
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
|
|
// an opaque surface the corners are simply clipped square.
|
|
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
|
|
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
|
|
|
|
// modern: the native frame stays. On macOS it fuses with the app's own
|
|
// header row (hiddenInset), and the traffic lights overlay it — which is
|
|
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
|
|
return process.platform === "darwin"
|
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
|
: {};
|
|
}
|
|
|
|
/**
|
|
* Windows are created hidden and revealed on the webview's first dom-ready —
|
|
* showing them earlier paints the native (white) surface before the themed
|
|
* page exists. The fallback timer guarantees a missed event can never leave
|
|
* a window invisible.
|
|
*/
|
|
export function showWhenReady(win: BrowserWindow): void {
|
|
let shown = false;
|
|
const show = () => {
|
|
if (shown) return;
|
|
shown = true;
|
|
win.show();
|
|
};
|
|
const fallback = setTimeout(show, 800);
|
|
win.webview.on("dom-ready", () => {
|
|
clearTimeout(fallback);
|
|
show();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Push maximize-state changes to a window's webview so the win7 title bar can
|
|
* swap its maximize/restore glyph.
|
|
*
|
|
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
|
|
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
|
|
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
|
|
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
|
|
* it per resize is cheap; the last-value guard keeps the wire quiet during a
|
|
* drag-resize. The emitter has no off(), so this closure must not capture
|
|
* anything that needs to be garbage collected (see WindowRegistry.track).
|
|
*/
|
|
export function trackMaximizeState(
|
|
win: BrowserWindow,
|
|
push: (maximized: boolean) => void,
|
|
): void {
|
|
let last = win.isMaximized();
|
|
win.on("resize", () => {
|
|
const now = win.isMaximized();
|
|
if (now === last) return;
|
|
last = now;
|
|
push(now);
|
|
});
|
|
}
|
|
|
|
export interface SecondaryWindowOptions {
|
|
title: string;
|
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
|
url: string;
|
|
mode: "editor" | "manager";
|
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
|
settings: AppSettings;
|
|
ctx?: unknown;
|
|
frame: { x: number; y: number; width: number; height: number };
|
|
/** Per-window rpc instance. NEVER share one across windows — each
|
|
* BrowserView steals the rpc's transport when it binds. */
|
|
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
|
}
|
|
|
|
/**
|
|
* App-chromed secondary window. The frame decision is delegated to
|
|
* chromeOptions (the single frame decision).
|
|
*/
|
|
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
|
const win = new BrowserWindow({
|
|
title: opts.title,
|
|
url: opts.url,
|
|
frame: opts.frame,
|
|
...chromeOptions(),
|
|
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
|
rpc: opts.rpc,
|
|
hidden: true,
|
|
});
|
|
showWhenReady(win);
|
|
return win;
|
|
}
|
|
|
|
/**
|
|
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
|
|
* an anchor window, focus tracking, close cleanup. A future "window follows
|
|
* window" grouping step slots in here — the registry already owns the window
|
|
* handles and the anchor it would need (anchor.on('move') + setPosition).
|
|
*/
|
|
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
|
|
private records = new Map<string, Rec>();
|
|
private focused = new Set<string>();
|
|
private opened = 0;
|
|
|
|
constructor(private anchorFrame: () => { x: number; y: number }) {}
|
|
|
|
get(key: string): Rec | undefined {
|
|
return this.records.get(key);
|
|
}
|
|
|
|
/** True (and focuses the window) when the key already has a live window. */
|
|
activateExisting(key: string): boolean {
|
|
const rec = this.records.get(key);
|
|
if (!rec) return false;
|
|
rec.win.activate();
|
|
return true;
|
|
}
|
|
|
|
set(key: string, rec: Rec): void {
|
|
this.records.set(key, rec);
|
|
}
|
|
|
|
/** Cascade off the anchor so stacked windows never hide each other fully. */
|
|
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
|
|
const anchor = this.anchorFrame();
|
|
const step = (this.opened++ % 8) * 28;
|
|
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
|
|
}
|
|
|
|
/**
|
|
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
|
|
* so these closures capture only (this, key) — never the record — letting
|
|
* closed-window content be garbage collected after delete().
|
|
*/
|
|
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
|
|
win.on("focus", () => this.focused.add(key));
|
|
win.on("blur", () => this.focused.delete(key));
|
|
win.on("close", () => {
|
|
this.focused.delete(key);
|
|
onClose(key);
|
|
});
|
|
}
|
|
|
|
delete(key: string): void {
|
|
this.records.delete(key);
|
|
}
|
|
|
|
anyFocused(): boolean {
|
|
return this.focused.size > 0;
|
|
}
|
|
|
|
focusedKey(): string | null {
|
|
for (const key of this.focused) return key;
|
|
return null;
|
|
}
|
|
|
|
forEach(fn: (rec: Rec, key: string) => void): void {
|
|
for (const [key, rec] of this.records) fn(rec, key);
|
|
}
|
|
|
|
size(): number {
|
|
return this.records.size;
|
|
}
|
|
}
|