Files
Panama/config/dot/quickshell/services/ThemeProfiles.qml
T
Gabriel Brown cb7c09d208 Give the desktop real themes, video wallpapers, and honest titlebars
Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 23:39:04 -04:00

204 lines
8.9 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 key = target === "light" ? "themeLight" : "themeDark";
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const stored = String(DesktopPreferences.get(key) || "");
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
? 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));
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 {
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(); }
}
}