Files

219 lines
9.8 KiB
QML

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(); }
}
}