pragma Singleton // The theme resolver. Shipped themes come from ThemeCatalog (config/themes.json), // customs from the themeProfiles preference; the active theme is one record with // a full palette, resolved here for Theme.qml and the render pipeline. // // Selection is per-mode: themeDark and themeLight remember the chosen theme for // each scheme, so flipping light/dark lands on *your* theme for that side — // never a forced reset to the defaults. Every commit path ends in commitActive, // which also keeps accentName (GNOME's enum, kitty's border, the lock screen) // pointed at the nearest curated accent — the desync those surfaces used to // suffer after a custom edit is structural now, not a convention. import Quickshell import QtQuick import qs.config import "ThemeProfileModel.js" as ThemeProfileModel Singleton { id: root readonly property var shippedThemes: ThemeCatalog.themes readonly property var customProfiles: ThemeProfileModel.validCustomProfiles( DesktopPreferences.get("themeProfiles"), root.shippedThemes) readonly property var curatedAccents: ThemeProfileModel.curatedAccents() readonly property var profiles: ThemeProfileModel.profileCatalog( root.customProfiles, root.shippedThemes) readonly property string scheme: DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark" readonly property string activeId: DesktopPreferences.get("themeProfileId") || ThemeCatalog.defaultDark readonly property var activeProfile: ThemeProfileModel.findProfile( root.customProfiles, root.activeId, root.shippedThemes) || ThemeProfileModel.shippedProfiles(root.shippedThemes)[0] readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile( root.activeProfile) // Always complete: the record's own palette, or the scheme default's. readonly property var activePalette: root.activeProfile.palette ?? ThemeCatalog.defaultPalette(root.activeProfile.scheme) readonly property var activeAnsi: root.activeProfile.ansi ?? (ThemeCatalog.byId(root.activeProfile.scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)?.ansi ?? null) property bool reconciling: false // The remembered theme for a scheme, falling back to the catalog default. function themeForScheme(target: string): string { const scheme = target === "light" ? "light" : "dark"; const key = scheme === "light" ? "themeLight" : "themeDark"; const fallback = scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark; const stored = String(DesktopPreferences.get(key) || ""); const profile = ThemeProfileModel.findProfile( root.customProfiles, stored, root.shippedThemes); // Existing is not enough: the slot has to hold a theme OF that scheme. // A custom theme edited from dark to light stayed in the dark slot, so // asking for dark selected a light theme, which flipped the scheme back // to light -- the light/dark toggle simply stopped working. return profile && profile.scheme === scheme ? stored : fallback; } function commitActive(profile: var): bool { if (!profile) return false; // Scheme first: if another control changed it, the reconciliation hook // may briefly select the scheme default before this final id lands. const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme); const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id); SystemSettings.commitPreference( profile.scheme === "light" ? "themeLight" : "themeDark", profile.id); SystemSettings.commitPreference("accentName", ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent)); // Effects are the theme's own snapshot of blur, shadow and motion. // Restoring them for a theme that did not actually become active left // the desktop wearing half of a theme nobody selected. if (schemeAccepted && profileAccepted) root.applyProfileEffects(profile); return schemeAccepted && profileAccepted; } // A theme that carries an effects snapshot restores it; themes without one // (every shipped theme) leave the user's effects alone. function applyProfileEffects(profile: var): void { const effects = profile.effects ?? null; if (!effects) return; for (const key of Object.keys(effects)) SystemSettings.commitPreference(key, effects[key]); } function selectProfile(id: string): bool { return root.commitActive(ThemeProfileModel.findProfile( root.customProfiles, id, root.shippedThemes)); } // The Themes tab's light/dark hero: land on the remembered theme for the // requested scheme. function setScheme(target: string): bool { return root.selectProfile(root.themeForScheme(target === "light" ? "light" : "dark")); } // Editing a shipped profile creates a saved custom copy. Editing a custom // profile updates only that record. ThemeProfileModel enforces both rules, // leaving this singleton responsible only for validated preference routing. function updateActive(changes: var): bool { const result = ThemeProfileModel.editProfile( root.customProfiles, root.activeProfile, changes, root.shippedThemes); if (!result.profile) return false; if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) return false; return root.commitActive(result.profile); } function setAccentPair(accent: string, secondary: string): bool { return root.updateActive({ accent: accent, secondary: secondary }); } // The editor's Background/Foreground wells: derive the surface and text // families from the two ground colors, keep everything else. function setGroundColors(bg: string, fg: string): bool { const palette = ThemeProfileModel.derivePalette(root.activePalette, { scheme: root.activeProfile.scheme, bg: bg, fg: fg, accent: root.activeProfile.accent }); if (!palette) return false; return root.updateActive({ palette: palette, ansi: root.activeProfile.ansi ?? ThemeProfileModel.deriveAnsi(palette, root.activeProfile.accent) }); } // The editor's saturation slider. factor 1.0 is neutral. function setSaturation(factor: real): bool { // NaN survives the model's clamp and comes back out as a palette of // unreadable tokens. Reject it here, where there is still a caller to // tell about it. if (!isFinite(factor)) return false; const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor); if (!palette) return false; return root.updateActive({ palette: palette }); } // Saving snapshots the whole look: palette, terminal colors, and the ten // effect values as they stand right now. function saveProfile(name: string): bool { const effects = {}; for (const key of Object.keys(ThemeProfileModel.EFFECT_SPEC)) effects[key] = DesktopPreferences.get(key); const result = ThemeProfileModel.createCustomProfile(root.customProfiles, { name: name, scheme: root.activeProfile.scheme, accent: root.activeProfile.accent, secondary: root.activeProfile.secondary, palette: root.activePalette, ansi: root.activeAnsi, effects: effects }, root.shippedThemes); if (!result.profile) return false; if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) return false; return root.commitActive(result.profile); } function deleteProfile(id: string): bool { const result = ThemeProfileModel.deleteProfile( root.customProfiles, id, root.shippedThemes); if (!result.removed) return false; if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) return false; if (root.activeId === id) return root.setScheme(root.scheme); return true; } // Accepts the curated name or the AccentPicker's option object — the // picker used to hand the object to a string parameter and silently fall // back to blue. function useCuratedAccent(entry: var): bool { const name = typeof entry === "string" ? entry : String(entry?.value ?? entry?.name ?? ""); const pair = ThemeProfileModel.curatedPair(name, root.scheme); const shipped = ThemeProfileModel.matchingShippedProfile( root.scheme, pair.accent, pair.secondary, root.shippedThemes); const accepted = shipped ? root.commitActive(shipped) : root.setAccentPair(pair.accent, pair.secondary); if (accepted && ThemeProfileModel.curatedAccents()[name]) SystemSettings.commitPreference("accentName", name); return accepted; } // A scheme flip from anywhere (the hero, IPC, a synced settings file) // lands on the remembered theme for that scheme — never a forced default. function reconcileScheme(): void { if (root.reconciling) return; if (root.activeProfile.scheme === root.scheme) return; root.reconciling = true; SystemSettings.commitPreference("themeProfileId", root.themeForScheme(root.scheme)); root.reconciling = false; } Component.onCompleted: root.reconcileScheme() Connections { target: DesktopPreferences function onRevisionChanged(): void { root.reconcileScheme(); } } }