219 lines
9.8 KiB
QML
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(); }
|
|
}
|
|
}
|