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
This commit is contained in:
@@ -1,17 +1,25 @@
|
||||
pragma Singleton
|
||||
|
||||
// Keeps everything that draws a window agreeing about light or dark.
|
||||
// Keeps everything that draws a window agreeing about the active theme.
|
||||
//
|
||||
// The shell repaints itself from Theme.qml the moment the preference changes,
|
||||
// because every token there is a binding. Two other things draw on this desktop
|
||||
// and do not read Panama's store:
|
||||
// The shell repaints itself from Theme.qml the moment a preference changes,
|
||||
// because every token there is a binding. Three other things draw on this
|
||||
// desktop and do not read Panama's store:
|
||||
//
|
||||
// GTK applications read gsettings color-scheme and gtk-theme
|
||||
// the compositor draws window borders and shadows
|
||||
// everything else carries its own palette file, rendered by
|
||||
// scripts/panama-theme-apps
|
||||
//
|
||||
// A toolbar or a window border still wearing the other scheme is more jarring
|
||||
// than either scheme on its own, which is why this exists rather than the
|
||||
// setting simply being a Theme property.
|
||||
// A toolbar or a window border still wearing the previous theme is more jarring
|
||||
// than any theme on its own, which is why this exists rather than the setting
|
||||
// simply being a Theme property.
|
||||
//
|
||||
// It watches three things, not one: the scheme, the nearest curated accent
|
||||
// NAME (which is all GNOME's enum can take), and the active theme's palette.
|
||||
// The third is the reason editing a custom theme's background reaches kitty --
|
||||
// the id does not change across an edit, and the accent name often does not
|
||||
// change across a theme selection either.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -24,32 +32,54 @@ Singleton {
|
||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||
// to the visual theme and must remain untouched when this role changes.
|
||||
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||
|
||||
// The focused border follows the chosen accent. `ee` is the shipped alpha
|
||||
// for the Prism gradient; Theme owns which colors, this owns the form the
|
||||
// compositor wants them in.
|
||||
function hyprColor(value: var): string {
|
||||
// Theme owns which colors; this owns the form the compositor wants them in.
|
||||
// The alphas are the shipped ones for each border role.
|
||||
function hyprColor(value: var, alpha: string): string {
|
||||
// Qt gives "#rrggbb" -- or "#aarrggbb" if the color ever carries an
|
||||
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
|
||||
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
|
||||
// call it RGB. Hyprland wants rgba(rrggbbaa).
|
||||
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
|
||||
return "rgba(" + String(value).replace("#", "").slice(-6) + alpha + ")";
|
||||
}
|
||||
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
|
||||
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
|
||||
|
||||
// A neutral contrast role, not an accent. The focused Prism border below
|
||||
// belongs to the accent and must remain untouched when this role changes.
|
||||
//
|
||||
// It is the active theme's gutter -- the palette's own neutral surface --
|
||||
// rather than the two Tokyo Night literals that used to be written here.
|
||||
// Those were right for exactly two of the ten shipped themes and for no
|
||||
// custom one.
|
||||
readonly property string inactiveBorder: root.hyprColor(Theme.gutter, "99")
|
||||
|
||||
// The focused border follows the chosen accent.
|
||||
readonly property string accentBorderStart: root.hyprColor(Theme.accent, "ee")
|
||||
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary, "ee")
|
||||
property string lastError: ""
|
||||
|
||||
// A cheap fingerprint of what the render pipeline actually renders from.
|
||||
//
|
||||
// The theme id alone is not enough: editing a custom theme's background
|
||||
// keeps the same id, and the shell would repaint from the binding while
|
||||
// kitty, GTK and the lock screen kept the old colours -- the change would
|
||||
// look like it had only half landed. The values are in the key too, so any
|
||||
// edit to the active palette is a change worth pushing.
|
||||
readonly property string palettePrint: {
|
||||
const palette = ThemeProfiles.activePalette;
|
||||
if (!palette)
|
||||
return "";
|
||||
return Object.keys(palette).sort().map(token => palette[token]).join(",");
|
||||
}
|
||||
|
||||
// What the last push actually sent, so apply() can tell an accent-only
|
||||
// change apart from a scheme change and skip the steps that do not depend
|
||||
// on whichever did not move. Their starting values do not matter: the
|
||||
// first apply() always runs with force set, which ignores both.
|
||||
// change apart from a scheme or theme change and skip the steps that do
|
||||
// not depend on whichever did not move. Their starting values do not
|
||||
// matter: the first apply() always runs with force set, which ignores all
|
||||
// of them.
|
||||
property bool appliedDark: false
|
||||
property string appliedAccentName: ""
|
||||
property string appliedThemeId: ""
|
||||
property string appliedPalettePrint: ""
|
||||
|
||||
// Applied one command at a time: Process runs a single command, and several
|
||||
// of these are separate programs.
|
||||
@@ -119,10 +149,19 @@ Singleton {
|
||||
root.lastError = "";
|
||||
|
||||
const accentName = DesktopPreferences.get("accentName") || "blue";
|
||||
const themeId = ThemeProfiles.activeId;
|
||||
const palettePrint = root.palettePrint;
|
||||
|
||||
const schemeChanged = force || root.dark !== root.appliedDark;
|
||||
const accentChanged = force || accentName !== root.appliedAccentName;
|
||||
// A theme change is a palette change: selecting another theme, or
|
||||
// editing the active one. accentName is the NEAREST CURATED name, so
|
||||
// it can hold still across both -- Moon to Moon Rose keeps "blue" and
|
||||
// moves every other colour.
|
||||
const themeChanged = force || themeId !== root.appliedThemeId
|
||||
|| palettePrint !== root.appliedPalettePrint;
|
||||
|
||||
if (!schemeChanged && !accentChanged)
|
||||
if (!schemeChanged && !accentChanged && !themeChanged)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
@@ -147,17 +186,21 @@ Singleton {
|
||||
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
|
||||
}
|
||||
|
||||
if (schemeChanged || themeChanged) {
|
||||
// Unfocused window borders need scheme-relative contrast, and stay
|
||||
// this service's to own. The FOCUSED border below is the accent
|
||||
// role and belongs to the theme -- see modules/settings/README.md.
|
||||
// Unlike that role, this one does not depend on the accent, so an
|
||||
// accent-only change never needs to restate it.
|
||||
//
|
||||
// It is drawn from the palette's gutter, so it moves with the THEME
|
||||
// as well as with the scheme; an accent-only change still never
|
||||
// needs to restate it.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
}
|
||||
|
||||
if (schemeChanged || accentChanged) {
|
||||
if (schemeChanged || accentChanged || themeChanged) {
|
||||
// Each accent carries a separate pair for light and dark, so this
|
||||
// runs on either kind of change: a scheme flip restates the same
|
||||
// accent's other pair, and an accent change restates the same
|
||||
@@ -178,23 +221,25 @@ Singleton {
|
||||
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry
|
||||
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
|
||||
// Qt6, Chromium, Electron) already have their SCHEME from the
|
||||
// gsettings write above.
|
||||
// their own palettes -- terminals, GTK's css, the lock screen, the
|
||||
// launcher, the browser chrome. Portal readers (GTK4, Qt6, Chromium,
|
||||
// Electron) already have their SCHEME from the gsettings write
|
||||
// above, but no portal can tell them which THEME.
|
||||
//
|
||||
// Their ACCENT does not come from here: GNOME's accent-color is a
|
||||
// fixed enum of nine names rather than a color, so the mapping from
|
||||
// our eight lives in panama-theme-apps beside the other per-
|
||||
// application translations. The accent is passed through for that,
|
||||
// and because kitty's border and the hyprlock fallback template are
|
||||
// the accent role too -- so this still has to run on an accent-only
|
||||
// change, even though the scheme-relative work it also does is then
|
||||
// redundant.
|
||||
// The script resolves the palette itself, from settings.json and
|
||||
// the catalog, so the two arguments here are only what it cannot
|
||||
// read: nothing, and the nearest curated accent NAME, which GNOME's
|
||||
// accent-color enum needs because it takes a name rather than a
|
||||
// color. It runs on a theme change even when neither scheme nor
|
||||
// accentName moved -- that is what selecting Nord over Moon looks
|
||||
// like from here.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||
}
|
||||
|
||||
root.appliedDark = root.dark;
|
||||
root.appliedAccentName = accentName;
|
||||
root.appliedThemeId = themeId;
|
||||
root.appliedPalettePrint = palettePrint;
|
||||
|
||||
root.enqueue(commands);
|
||||
}
|
||||
|
||||
@@ -174,15 +174,17 @@ Singleton {
|
||||
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||
}
|
||||
|
||||
// Close only, on the configured side. Minimize would be a lie (Hyprland
|
||||
// has no minimize) and maximize is noise in a tiler, so neither is
|
||||
// offered — not as a button, not as a setting.
|
||||
function buttonLayout(): string {
|
||||
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||
|
||||
// Tokens are fixed. Only their side and whether maximize is present
|
||||
// vary, so preference data can never become command syntax.
|
||||
// Tokens are fixed. Only their side varies, so preference data can
|
||||
// never become command syntax.
|
||||
if (side === "left")
|
||||
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||
return "close:appmenu";
|
||||
return "appmenu:close";
|
||||
}
|
||||
|
||||
function setting(schema: string, key: string, value: var): var {
|
||||
@@ -207,8 +209,6 @@ Singleton {
|
||||
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||
DesktopPreferences.get("middleClickPaste")),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||
DesktopPreferences.get("titlebarDoubleClick")),
|
||||
// The portal republishes this as org.freedesktop.appearance
|
||||
// contrast, which is what libadwaita reads -- so this reaches GTK4
|
||||
// applications without any high-contrast theme being installed.
|
||||
|
||||
@@ -153,6 +153,16 @@ Singleton {
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
|
||||
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
|
||||
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" },
|
||||
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" },
|
||||
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" },
|
||||
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" },
|
||||
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" },
|
||||
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
|
||||
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
// The shipped theme catalog, read from config/themes.json — full palettes for
|
||||
// every theme Panama ships, one file shared with the scripts (jq) and the
|
||||
// Node halves of contracts (fs). QML reads it here with FileView rather than
|
||||
// keeping a duplicated JS table: unlike palette.json's CURATED twin, nothing
|
||||
// in this file needs to run under Node.
|
||||
//
|
||||
// The embedded fallback below carries only the two default themes (Moon and
|
||||
// Day) so the shell renders correctly for the instant before the file loads —
|
||||
// and forever if it is missing or malformed. theme-catalog-contract pins the
|
||||
// fallback byte-identical to the catalog's moon and day records.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ id, name, scheme, accent, secondary, palette: {19 tokens}, ansi: {16} }]
|
||||
property var themes: root.fallbackThemes
|
||||
property string defaultDark: "moon"
|
||||
property string defaultLight: "day"
|
||||
property bool loaded: false
|
||||
property string lastError: ""
|
||||
|
||||
function byId(id: string): var {
|
||||
return root.themes.find(theme => theme.id === id) ?? null;
|
||||
}
|
||||
|
||||
// The palette every resolution falls back to: the default theme for the
|
||||
// scheme. Always complete, even before the file loads.
|
||||
function defaultPalette(scheme: string): var {
|
||||
const id = scheme === "light" ? root.defaultLight : root.defaultDark;
|
||||
const theme = root.byId(id) ?? root.fallbackThemes[scheme === "light" ? 1 : 0];
|
||||
return theme.palette;
|
||||
}
|
||||
|
||||
function themesFor(scheme: string): var {
|
||||
return root.themes.filter(theme => theme.scheme === scheme);
|
||||
}
|
||||
|
||||
// A record is only accepted with every palette key present and valid, so
|
||||
// nothing downstream ever needs a per-token fallback.
|
||||
readonly property var paletteKeys: ["bg", "bgDark", "bgHighlight", "bgPanel",
|
||||
"bgPopover", "fg", "fgDim", "fgMuted", "gutter", "accentAlt",
|
||||
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
|
||||
"magenta", "pink"]
|
||||
readonly property var ansiKeys: ["black", "red", "green", "yellow", "blue",
|
||||
"magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen",
|
||||
"brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||
|
||||
function validHex(value: var): bool {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value);
|
||||
}
|
||||
|
||||
function normalizeTheme(entry: var): var {
|
||||
if (!entry || typeof entry !== "object")
|
||||
return null;
|
||||
const id = String(entry.id ?? "");
|
||||
const name = String(entry.name ?? "");
|
||||
const scheme = String(entry.scheme ?? "");
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id) || name === ""
|
||||
|| (scheme !== "dark" && scheme !== "light"))
|
||||
return null;
|
||||
if (!root.validHex(entry.accent) || !root.validHex(entry.secondary))
|
||||
return null;
|
||||
const palette = {};
|
||||
for (const key of root.paletteKeys) {
|
||||
const value = entry.palette?.[key];
|
||||
if (!root.validHex(value))
|
||||
return null;
|
||||
palette[key] = value;
|
||||
}
|
||||
const ansi = {};
|
||||
for (const key of root.ansiKeys) {
|
||||
const value = entry.ansi?.[key];
|
||||
if (!root.validHex(value))
|
||||
return null;
|
||||
ansi[key] = value;
|
||||
}
|
||||
return {
|
||||
id: id, name: name, scheme: scheme, shipped: true,
|
||||
accent: entry.accent, secondary: entry.secondary,
|
||||
palette: palette, ansi: ansi
|
||||
};
|
||||
}
|
||||
|
||||
function consume(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const themes = (parsed.themes ?? [])
|
||||
.map(entry => root.normalizeTheme(entry))
|
||||
.filter(theme => theme !== null);
|
||||
if (themes.length === 0)
|
||||
throw new Error("no valid themes");
|
||||
root.themes = themes;
|
||||
root.defaultDark = String(parsed.defaultDark ?? "moon");
|
||||
root.defaultLight = String(parsed.defaultLight ?? "day");
|
||||
root.loaded = true;
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "The theme catalog could not be read; using the built-in themes.";
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: catalogFile
|
||||
path: Quickshell.shellDir + "/config/themes.json"
|
||||
printErrors: false
|
||||
onLoaded: root.consume(this.text())
|
||||
onLoadFailed: root.lastError =
|
||||
"The theme catalog is missing; using the built-in themes."
|
||||
}
|
||||
|
||||
// Byte-identical to the catalog's moon and day records — the shell's
|
||||
// pre-theme literals, pinned by theme-catalog-contract.
|
||||
readonly property var fallbackThemes: [
|
||||
{
|
||||
id: "moon", name: "Tokyo Moon", scheme: "dark", shipped: true,
|
||||
accent: "#82aaff", secondary: "#b172b0",
|
||||
palette: {
|
||||
bg: "#222436", bgDark: "#1e2030", bgHighlight: "#2f334d",
|
||||
bgPanel: "#2e2f3d", bgPopover: "#21212f",
|
||||
fg: "#c8d3f5", fgDim: "#828bb8", fgMuted: "#636da6",
|
||||
gutter: "#3b4261", accentAlt: "#65bcff",
|
||||
cyan: "#86e1fc", teal: "#4fd6be", green: "#c3e88d",
|
||||
yellow: "#ffc777", orange: "#ff966c", red: "#ff757f",
|
||||
redDeep: "#c53b53", magenta: "#c099ff", pink: "#fca7ea"
|
||||
},
|
||||
ansi: {
|
||||
black: "#1b1d2b", red: "#ff757f", green: "#c3e88d",
|
||||
yellow: "#ffc777", blue: "#82aaff", magenta: "#c099ff",
|
||||
cyan: "#86e1fc", white: "#828bb8",
|
||||
brightBlack: "#444a73", brightRed: "#ff8d94", brightGreen: "#c7fb6d",
|
||||
brightYellow: "#ffd8ab", brightBlue: "#9ab8ff", brightMagenta: "#caabff",
|
||||
brightCyan: "#b2ebff", brightWhite: "#c8d3f5"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "day", name: "Tokyo Day", scheme: "light", shipped: true,
|
||||
accent: "#2e7de9", secondary: "#9854f1",
|
||||
palette: {
|
||||
bg: "#e1e2e7", bgDark: "#d3d5de", bgHighlight: "#c4c8da",
|
||||
bgPanel: "#d9dae3", bgPopover: "#eaeaee",
|
||||
fg: "#3760bf", fgDim: "#6172b0", fgMuted: "#848cb5",
|
||||
gutter: "#a8aecb", accentAlt: "#007197",
|
||||
cyan: "#007197", teal: "#118c74", green: "#587539",
|
||||
yellow: "#8c6c3e", orange: "#b15c00", red: "#f52a65",
|
||||
redDeep: "#c64343", magenta: "#9854f1", pink: "#d20065"
|
||||
},
|
||||
ansi: {
|
||||
black: "#b4b5b9", red: "#f52a65", green: "#587539",
|
||||
yellow: "#8c6c3e", blue: "#2e7de9", magenta: "#9854f1",
|
||||
cyan: "#007197", white: "#6172b0",
|
||||
brightBlack: "#a1a6c5", brightRed: "#ff4774", brightGreen: "#5c8524",
|
||||
brightYellow: "#a27629", brightBlue: "#358aff", brightMagenta: "#a463ff",
|
||||
brightCyan: "#007ea8", brightWhite: "#3760bf"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
var MAX_NAME_LENGTH = 40;
|
||||
|
||||
// The token sets a theme record may carry beyond its accent pair. PALETTE_KEYS
|
||||
// mirrors Theme.qml's color tokens; ANSI_KEYS feed generated terminal themes;
|
||||
// EFFECT_SPEC mirrors the ten effects schema keys so a custom theme can
|
||||
// snapshot them. All three are pinned by theme-catalog-contract against
|
||||
// config/themes.json.
|
||||
var PALETTE_KEYS = ["bg", "bgDark", "bgHighlight", "bgPanel", "bgPopover",
|
||||
"fg", "fgDim", "fgMuted", "gutter", "accentAlt",
|
||||
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
|
||||
"magenta", "pink"];
|
||||
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan",
|
||||
"white", "brightBlack", "brightRed", "brightGreen", "brightYellow",
|
||||
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
|
||||
var EFFECT_SPEC = {
|
||||
blurEnabled: { kind: "bool" },
|
||||
blurSize: { kind: "int", min: 1, max: 20 },
|
||||
blurPasses: { kind: "int", min: 1, max: 5 },
|
||||
shadowEnabled: { kind: "bool" },
|
||||
shadowRange: { kind: "int", min: 0, max: 60 },
|
||||
shadowSharp: { kind: "bool" },
|
||||
shadowRenderPower: { kind: "int", min: 1, max: 4 },
|
||||
glowEnabled: { kind: "bool" },
|
||||
glowRange: { kind: "int", min: 0, max: 30 },
|
||||
animationsEnabled: { kind: "bool" }
|
||||
};
|
||||
|
||||
// The minimal built-in shipped list. The full catalog (config/themes.json,
|
||||
// via ThemeCatalog) is passed into every function that takes a `shipped`
|
||||
// argument; this fallback keeps the model usable headless and before the
|
||||
// catalog loads.
|
||||
var SHIPPED = [
|
||||
{
|
||||
id: "moon",
|
||||
@@ -85,8 +114,53 @@ var CURATED = {
|
||||
}
|
||||
};
|
||||
|
||||
function copyColorMap(value, keys) {
|
||||
if (!value || typeof value !== "object")
|
||||
return null;
|
||||
var result = {};
|
||||
for (var index = 0; index < keys.length; index++) {
|
||||
var color = normalizedColor(value[keys[index]]);
|
||||
if (!color)
|
||||
return null;
|
||||
result[keys[index]] = color;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A palette or ansi block is all-or-nothing: a record either carries every
|
||||
// key valid, or the field is dropped and resolution falls back to the
|
||||
// scheme's default theme. Effects are per-key: invalid entries are dropped.
|
||||
function normalizePalette(value) { return copyColorMap(value, PALETTE_KEYS); }
|
||||
function normalizeAnsi(value) { return copyColorMap(value, ANSI_KEYS); }
|
||||
|
||||
function normalizeEffects(value) {
|
||||
if (!value || typeof value !== "object")
|
||||
return null;
|
||||
var result = {};
|
||||
var any = false;
|
||||
Object.keys(EFFECT_SPEC).forEach(function(key) {
|
||||
var spec = EFFECT_SPEC[key];
|
||||
var raw = value[key];
|
||||
if (raw === undefined)
|
||||
return;
|
||||
if (spec.kind === "bool") {
|
||||
if (typeof raw !== "boolean")
|
||||
return;
|
||||
result[key] = raw;
|
||||
any = true;
|
||||
return;
|
||||
}
|
||||
var number = Number(raw);
|
||||
if (!isFinite(number))
|
||||
return;
|
||||
result[key] = Math.round(clamp(number, spec.min, spec.max));
|
||||
any = true;
|
||||
});
|
||||
return any ? result : null;
|
||||
}
|
||||
|
||||
function copyProfile(profile) {
|
||||
return {
|
||||
var result = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
scheme: profile.scheme,
|
||||
@@ -94,10 +168,24 @@ function copyProfile(profile) {
|
||||
secondary: profile.secondary,
|
||||
shipped: profile.shipped === true
|
||||
};
|
||||
var palette = normalizePalette(profile.palette);
|
||||
if (palette)
|
||||
result.palette = palette;
|
||||
var ansi = normalizeAnsi(profile.ansi);
|
||||
if (ansi)
|
||||
result.ansi = ansi;
|
||||
var effects = normalizeEffects(profile.effects);
|
||||
if (effects)
|
||||
result.effects = effects;
|
||||
return result;
|
||||
}
|
||||
|
||||
function shippedProfiles() {
|
||||
return SHIPPED.map(copyProfile);
|
||||
function shippedList(shipped) {
|
||||
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
|
||||
}
|
||||
|
||||
function shippedProfiles(shipped) {
|
||||
return shippedList(shipped).map(copyProfile);
|
||||
}
|
||||
|
||||
function curatedAccents() {
|
||||
@@ -130,7 +218,7 @@ function normalizeStoredProfile(value) {
|
||||
|| !isScheme(value.scheme) || !accent || !secondary)
|
||||
return null;
|
||||
|
||||
return {
|
||||
var result = {
|
||||
id: id,
|
||||
name: name,
|
||||
scheme: value.scheme,
|
||||
@@ -138,15 +226,25 @@ function normalizeStoredProfile(value) {
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
var palette = normalizePalette(value.palette);
|
||||
if (palette)
|
||||
result.palette = palette;
|
||||
var ansi = normalizeAnsi(value.ansi);
|
||||
if (ansi)
|
||||
result.ansi = ansi;
|
||||
var effects = normalizeEffects(value.effects);
|
||||
if (effects)
|
||||
result.effects = effects;
|
||||
return result;
|
||||
}
|
||||
|
||||
function validCustomProfiles(values) {
|
||||
function validCustomProfiles(values, shipped) {
|
||||
if (!Array.isArray(values))
|
||||
return [];
|
||||
|
||||
var ids = {};
|
||||
var names = {};
|
||||
SHIPPED.forEach(function(profile) {
|
||||
shippedList(shipped).forEach(function(profile) {
|
||||
ids[profile.id] = true;
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
@@ -166,8 +264,8 @@ function validCustomProfiles(values) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function profileCatalog(values) {
|
||||
return shippedProfiles().concat(validCustomProfiles(values));
|
||||
function profileCatalog(values, shipped) {
|
||||
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
|
||||
}
|
||||
|
||||
function boundedName(value) {
|
||||
@@ -218,9 +316,9 @@ function uniqueId(name, profiles) {
|
||||
return base.slice(0, 52) + "-10000";
|
||||
}
|
||||
|
||||
function createCustomProfile(values, input) {
|
||||
var customs = validCustomProfiles(values);
|
||||
var catalog = shippedProfiles().concat(customs);
|
||||
function createCustomProfile(values, input, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
var catalog = shippedProfiles(shipped).concat(customs);
|
||||
var scheme = input && input.scheme;
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var secondary = normalizedColor(input && input.secondary);
|
||||
@@ -236,11 +334,20 @@ function createCustomProfile(values, input) {
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
var palette = normalizePalette(input && input.palette);
|
||||
if (palette)
|
||||
profile.palette = palette;
|
||||
var ansi = normalizeAnsi(input && input.ansi);
|
||||
if (ansi)
|
||||
profile.ansi = ansi;
|
||||
var effects = normalizeEffects(input && input.effects);
|
||||
if (effects)
|
||||
profile.effects = effects;
|
||||
return { profiles: customs.concat([profile]), profile: profile };
|
||||
}
|
||||
|
||||
function findProfile(values, id) {
|
||||
var catalog = profileCatalog(values);
|
||||
function findProfile(values, id, shipped) {
|
||||
var catalog = profileCatalog(values, shipped);
|
||||
for (var index = 0; index < catalog.length; index++) {
|
||||
if (catalog[index].id === id)
|
||||
return catalog[index];
|
||||
@@ -248,26 +355,32 @@ function findProfile(values, id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function editProfile(values, selected, changes) {
|
||||
var customs = validCustomProfiles(values);
|
||||
function editProfile(values, selected, changes, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
if (!selected || typeof selected !== "object")
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var accent = normalizedColor(changes && changes.accent !== undefined
|
||||
? changes.accent : selected.accent);
|
||||
var secondary = normalizedColor(changes && changes.secondary !== undefined
|
||||
? changes.secondary : selected.secondary);
|
||||
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
|
||||
function pick(field, fallback) {
|
||||
return changes && changes[field] !== undefined ? changes[field] : fallback;
|
||||
}
|
||||
var accent = normalizedColor(pick("accent", selected.accent));
|
||||
var secondary = normalizedColor(pick("secondary", selected.secondary));
|
||||
var scheme = pick("scheme", selected.scheme);
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
if (selected.shipped === true) {
|
||||
// Editing a shipped theme forks it into a custom, carrying the whole
|
||||
// palette so the fork looks identical until the edit lands.
|
||||
return createCustomProfile(customs, {
|
||||
name: selected.name + " custom",
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
secondary: secondary,
|
||||
palette: pick("palette", selected.palette),
|
||||
ansi: pick("ansi", selected.ansi),
|
||||
effects: pick("effects", selected.effects)
|
||||
}, shipped);
|
||||
}
|
||||
|
||||
var stored = normalizeStoredProfile(selected);
|
||||
@@ -279,6 +392,21 @@ function editProfile(values, selected, changes) {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
var palette = normalizePalette(pick("palette", stored.palette));
|
||||
if (palette)
|
||||
updated.palette = palette;
|
||||
else
|
||||
delete updated.palette;
|
||||
var ansi = normalizeAnsi(pick("ansi", stored.ansi));
|
||||
if (ansi)
|
||||
updated.ansi = ansi;
|
||||
else
|
||||
delete updated.ansi;
|
||||
var effects = normalizeEffects(pick("effects", stored.effects));
|
||||
if (effects)
|
||||
updated.effects = effects;
|
||||
else
|
||||
delete updated.effects;
|
||||
var found = false;
|
||||
var next = customs.map(function(profile) {
|
||||
if (profile.id !== updated.id)
|
||||
@@ -291,10 +419,11 @@ function editProfile(values, selected, changes) {
|
||||
return { profiles: next, profile: updated };
|
||||
}
|
||||
|
||||
function deleteProfile(values, id) {
|
||||
var customs = validCustomProfiles(values);
|
||||
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
|
||||
if (SHIPPED[shippedIndex].id === id)
|
||||
function deleteProfile(values, id, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
var fixed = shippedList(shipped);
|
||||
for (var shippedIndex = 0; shippedIndex < fixed.length; shippedIndex++) {
|
||||
if (fixed[shippedIndex].id === id)
|
||||
return { profiles: customs, removed: false };
|
||||
}
|
||||
var next = customs.filter(function(profile) { return profile.id !== id; });
|
||||
@@ -308,17 +437,131 @@ function curatedPair(name, scheme) {
|
||||
return { accent: entry.dark, secondary: entry.darkSecondary };
|
||||
}
|
||||
|
||||
function matchingShippedProfile(scheme, accent, secondary) {
|
||||
function matchingShippedProfile(scheme, accent, secondary, shipped) {
|
||||
var first = normalizedColor(accent);
|
||||
var second = normalizedColor(secondary);
|
||||
for (var index = 0; index < SHIPPED.length; index++) {
|
||||
var profile = SHIPPED[index];
|
||||
var fixed = shippedList(shipped);
|
||||
for (var index = 0; index < fixed.length; index++) {
|
||||
var profile = fixed[index];
|
||||
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
||||
return copyProfile(profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The nearest curated accent to an arbitrary pair, by hue distance of the
|
||||
// primary. This is what keeps `accentName` — and through it GNOME's
|
||||
// accent-color enum, kitty's border, and the lock screen — in sync when a
|
||||
// theme or a custom color is chosen instead of a curated swatch.
|
||||
function nearestCuratedName(scheme, accent) {
|
||||
var target = hexToHsv(accent);
|
||||
if (!target)
|
||||
return "blue";
|
||||
var bestName = "blue";
|
||||
var bestDistance = Infinity;
|
||||
Object.keys(CURATED).forEach(function(name) {
|
||||
var pair = curatedPair(name, isScheme(scheme) ? scheme : "dark");
|
||||
var candidate = hexToHsv(pair.accent);
|
||||
if (!candidate)
|
||||
return;
|
||||
var delta = Math.abs(candidate.h - target.h);
|
||||
var hueDistance = Math.min(delta, 360 - delta);
|
||||
// A desaturated accent should map to slate rather than whichever hue
|
||||
// its noise happens to lean toward.
|
||||
var distance = target.s < 12
|
||||
? (name === "slate" ? 0 : 1000 + hueDistance)
|
||||
: hueDistance + Math.abs(candidate.s - target.s) * 0.15;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestName = name;
|
||||
}
|
||||
});
|
||||
return bestName;
|
||||
}
|
||||
|
||||
// ── Palette derivation, for the editor ──────────────────────────────────────
|
||||
|
||||
function mixHex(first, second, ratio) {
|
||||
var a = normalizedColor(first);
|
||||
var b = normalizedColor(second);
|
||||
var r = clamp(ratio, 0, 1);
|
||||
if (!a || !b)
|
||||
return a || b;
|
||||
function channel(offset) {
|
||||
var from = parseInt(a.slice(offset, offset + 2), 16);
|
||||
var to = parseInt(b.slice(offset, offset + 2), 16);
|
||||
return channelHex(from + (to - from) * r);
|
||||
}
|
||||
return "#" + channel(1) + channel(3) + channel(5);
|
||||
}
|
||||
|
||||
// A full palette from the four editor wells plus a base theme. Background
|
||||
// derives the surface family, foreground derives the text tints, and every
|
||||
// other token is inherited from the base so a small edit stays a small edit.
|
||||
function derivePalette(base, input) {
|
||||
var origin = normalizePalette(base) || null;
|
||||
var bg = normalizedColor(input && input.bg) || (origin ? origin.bg : null);
|
||||
var fg = normalizedColor(input && input.fg) || (origin ? origin.fg : null);
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var scheme = isScheme(input && input.scheme) ? input.scheme : "dark";
|
||||
if (!bg || !fg)
|
||||
return origin;
|
||||
|
||||
var dark = scheme === "dark";
|
||||
var result = origin ? Object.assign({}, origin) : {};
|
||||
result.bg = bg;
|
||||
result.bgDark = mixHex(bg, "#000000", dark ? 0.10 : 0.06);
|
||||
result.bgPopover = dark ? mixHex(bg, "#000000", 0.06) : mixHex(bg, "#ffffff", 0.35);
|
||||
result.bgPanel = dark ? mixHex(bg, fg, 0.05) : mixHex(bg, "#000000", 0.03);
|
||||
result.bgHighlight = mixHex(bg, fg, dark ? 0.11 : 0.14);
|
||||
result.gutter = mixHex(bg, fg, dark ? 0.20 : 0.35);
|
||||
result.fg = fg;
|
||||
result.fgDim = mixHex(fg, bg, 0.32);
|
||||
result.fgMuted = mixHex(fg, bg, 0.47);
|
||||
if (accent)
|
||||
result.accentAlt = mixHex(accent, dark ? "#ffffff" : "#000000", 0.18);
|
||||
return normalizePalette(result);
|
||||
}
|
||||
|
||||
// Scale the saturation of every token together. factor 1.0 is neutral;
|
||||
// backgrounds move at quarter strength so the ground stays a ground.
|
||||
function resaturatePalette(palette, factor) {
|
||||
var origin = normalizePalette(palette);
|
||||
if (!origin)
|
||||
return null;
|
||||
var scale = clamp(factor, 0, 2);
|
||||
var result = {};
|
||||
PALETTE_KEYS.forEach(function(key) {
|
||||
var hsv = hexToHsv(origin[key]);
|
||||
var strength = key.indexOf("bg") === 0 || key === "gutter" ? 0.25 : 1;
|
||||
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
|
||||
result[key] = hsvToHex(hsv.h, next, hsv.v);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Terminal colors for a custom theme that has none of its own: semantic
|
||||
// tokens map straight onto ANSI, brights lift value a step.
|
||||
function deriveAnsi(palette, accent) {
|
||||
var origin = normalizePalette(palette);
|
||||
if (!origin)
|
||||
return null;
|
||||
var blue = normalizedColor(accent) || origin.accentAlt;
|
||||
function brighten(color) {
|
||||
var hsv = hexToHsv(color);
|
||||
return hsvToHex(hsv.h, Math.max(0, hsv.s - 8), Math.min(100, hsv.v + 12));
|
||||
}
|
||||
return normalizeAnsi({
|
||||
black: origin.bgHighlight, red: origin.red, green: origin.green,
|
||||
yellow: origin.yellow, blue: blue, magenta: origin.magenta,
|
||||
cyan: origin.cyan, white: origin.fgDim,
|
||||
brightBlack: origin.gutter, brightRed: brighten(origin.red),
|
||||
brightGreen: brighten(origin.green), brightYellow: brighten(origin.yellow),
|
||||
brightBlue: brighten(blue), brightMagenta: brighten(origin.magenta),
|
||||
brightCyan: brighten(origin.cyan), brightWhite: origin.fg
|
||||
});
|
||||
}
|
||||
|
||||
function curatedNameForProfile(profile) {
|
||||
if (!profile || !isScheme(profile.scheme))
|
||||
return "";
|
||||
@@ -402,6 +645,9 @@ function hexToHsv(value) {
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
||||
PALETTE_KEYS: PALETTE_KEYS,
|
||||
ANSI_KEYS: ANSI_KEYS,
|
||||
EFFECT_SPEC: EFFECT_SPEC,
|
||||
shippedProfiles: shippedProfiles,
|
||||
curatedAccents: curatedAccents,
|
||||
validCustomProfiles: validCustomProfiles,
|
||||
@@ -413,6 +659,14 @@ if (typeof module !== "undefined") {
|
||||
curatedPair: curatedPair,
|
||||
matchingShippedProfile: matchingShippedProfile,
|
||||
curatedNameForProfile: curatedNameForProfile,
|
||||
nearestCuratedName: nearestCuratedName,
|
||||
normalizePalette: normalizePalette,
|
||||
normalizeAnsi: normalizeAnsi,
|
||||
normalizeEffects: normalizeEffects,
|
||||
mixHex: mixHex,
|
||||
derivePalette: derivePalette,
|
||||
resaturatePalette: resaturatePalette,
|
||||
deriveAnsi: deriveAnsi,
|
||||
hsvToHex: hsvToHex,
|
||||
hexToHsv: hexToHsv
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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
|
||||
@@ -8,94 +19,178 @@ import "ThemeProfileModel.js" as ThemeProfileModel
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var shippedThemes: ThemeCatalog.themes
|
||||
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
|
||||
DesktopPreferences.get("themeProfiles"))
|
||||
DesktopPreferences.get("themeProfiles"), root.shippedThemes)
|
||||
readonly property var curatedAccents: ThemeProfileModel.curatedAccents()
|
||||
readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles)
|
||||
readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon"
|
||||
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) || ThemeProfileModel.shippedProfiles()[0]
|
||||
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 Day/Moon before this final stable profile id lands.
|
||||
// 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));
|
||||
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 setAccentPair(accent: string, secondary: string): bool {
|
||||
const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
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 SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
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
|
||||
});
|
||||
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 SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
return root.commitActive(result.profile);
|
||||
}
|
||||
|
||||
function deleteProfile(id: string): bool {
|
||||
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
|
||||
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.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon");
|
||||
return root.setScheme(root.scheme);
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCuratedAccent(name: string): bool {
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
const pair = ThemeProfileModel.curatedPair(name, scheme);
|
||||
// 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(
|
||||
scheme, pair.accent, pair.secondary);
|
||||
root.scheme, pair.accent, pair.secondary, root.shippedThemes);
|
||||
const accepted = shipped
|
||||
? root.commitActive(shipped)
|
||||
: root.setAccentPair(pair.accent, pair.secondary);
|
||||
if (accepted)
|
||||
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;
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
if (root.activeProfile.scheme === scheme)
|
||||
if (root.activeProfile.scheme === root.scheme)
|
||||
return;
|
||||
root.reconciling = true;
|
||||
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
|
||||
SystemSettings.commitPreference("themeProfileId", root.themeForScheme(root.scheme));
|
||||
root.reconciling = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
pragma Singleton
|
||||
|
||||
// Video wallpapers, played by mpvpaper — one supervised process per output,
|
||||
// hardware-decoded, muted, looped. Panama owns the pause policy, because the
|
||||
// compositor cannot be trusted with it: Hyprland rebuilds the full-screen blur
|
||||
// chain on every wallpaper frame while any blur-enabled layer exists, and an
|
||||
// occluded wallpaper keeps compositing unless solitary mode holds — which a
|
||||
// single toast breaks. So the wallpaper pauses itself whenever a game runs,
|
||||
// whenever the machine is on battery (if the preference says so), and whenever
|
||||
// the bar pill is clicked, over mpv's JSON IPC socket.
|
||||
//
|
||||
// hyprpaper and mpvpaper both claim the background layer and stacking within a
|
||||
// layer is creation order — a race. So while a video is active hyprpaper's
|
||||
// service is stopped, and stopping the video starts it again; Wallpaper.qml
|
||||
// reapplies the still policy once it returns.
|
||||
//
|
||||
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
|
||||
// workaround. Known upstream sharp edges — a hotplug segfault and a
|
||||
// mitigated-not-fixed leak — are why the processes are supervised and simply
|
||||
// respawned rather than trusted.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-video-wallpaper"
|
||||
|
||||
property bool available: false // mpvpaper on PATH, checked once
|
||||
property bool scanned: false
|
||||
property var candidates: [] // newest-60 videos in videoWallpaperDir
|
||||
property string path: "" // the active video, "" when inactive
|
||||
readonly property bool active: root.path !== ""
|
||||
property string lastError: ""
|
||||
|
||||
// Pause reasons, each independent; the video plays only when none holds.
|
||||
property bool manuallyPaused: false
|
||||
readonly property bool gamePaused: FocusModes.gameRunning
|
||||
readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true
|
||||
&& Battery.available && !Battery.acOnline
|
||||
readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused
|
||||
|
||||
readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== ""
|
||||
? Quickshell.env("XDG_STATE_HOME") + "/panama"
|
||||
: Quickshell.env("HOME") + "/.local/state/panama"
|
||||
// The lock screen cannot play video; panama-lock uses this still instead.
|
||||
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
|
||||
|
||||
readonly property var outputs: Quickshell.screens.map(screen => screen.name)
|
||||
|
||||
function videoDir(): string {
|
||||
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
|
||||
if (stored.startsWith("/"))
|
||||
return stored;
|
||||
if (stored.startsWith("~/"))
|
||||
return Quickshell.env("HOME") + stored.slice(1);
|
||||
return Quickshell.env("HOME") + "/" + stored;
|
||||
}
|
||||
|
||||
function isVideo(candidate: string): bool {
|
||||
return /\.(mp4|mkv|webm)$/i.test(String(candidate));
|
||||
}
|
||||
|
||||
function socketFor(output: string): string {
|
||||
return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock";
|
||||
}
|
||||
|
||||
// ── Discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
function rescan(): void {
|
||||
if (!scanProc.running) {
|
||||
scanProc.command = [root.helperPath, "scan", root.videoDir()];
|
||||
scanProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.candidates = this.text.split("\n").filter(line => line !== "");
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: probeProc
|
||||
command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"]
|
||||
running: true
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.available = this.text.trim() === "yes"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
// One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
|
||||
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is
|
||||
// named explicitly so a silent software-decode fallback cannot hide.
|
||||
function mpvpaperCommand(output: string): var {
|
||||
return ["mpvpaper", "-f", "-o",
|
||||
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
|
||||
+ "input-ipc-server=" + root.socketFor(output),
|
||||
"-p", "-a", "FULL",
|
||||
output, root.path];
|
||||
}
|
||||
|
||||
function start(video: string): bool {
|
||||
if (!root.available) {
|
||||
root.lastError = "mpvpaper is not installed — run panama update to pick it up.";
|
||||
return false;
|
||||
}
|
||||
if (!root.isVideo(video))
|
||||
return false;
|
||||
root.lastError = "";
|
||||
root.path = video;
|
||||
root.manuallyPaused = false;
|
||||
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
|
||||
hyprpaperControl.running = true;
|
||||
frameProc.command = ["sh", "-c",
|
||||
"mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath
|
||||
+ "' frame \"$1\" \"$2\"", "frame", video, root.framePath];
|
||||
frameProc.running = true;
|
||||
playerRespawn.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (!root.active)
|
||||
return;
|
||||
root.path = "";
|
||||
root.manuallyPaused = false;
|
||||
playerRespawn.stop();
|
||||
for (const player of root.players)
|
||||
player.running = false;
|
||||
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
||||
hyprpaperControl.running = true;
|
||||
Qt.callLater(() => Wallpaper.refreshActive());
|
||||
}
|
||||
|
||||
// Players are created per current output set each (re)spawn, so hotplug
|
||||
// lands on the same path as a crash: kill what runs, spawn fresh.
|
||||
property var players: []
|
||||
|
||||
Timer {
|
||||
id: playerRespawn
|
||||
interval: 400
|
||||
onTriggered: root.spawnPlayers()
|
||||
}
|
||||
|
||||
function spawnPlayers(): void {
|
||||
for (const player of root.players)
|
||||
player.running = false;
|
||||
const spawned = [];
|
||||
for (const output of root.outputs) {
|
||||
const player = playerComponent.createObject(root, { output: output });
|
||||
player.command = root.mpvpaperCommand(output);
|
||||
player.running = true;
|
||||
spawned.push(player);
|
||||
}
|
||||
root.players = spawned;
|
||||
pauseSync.restart();
|
||||
}
|
||||
|
||||
Component {
|
||||
id: playerComponent
|
||||
|
||||
Process {
|
||||
id: player
|
||||
property string output: ""
|
||||
onExited: {
|
||||
// A dead player while a video is meant to be active is a
|
||||
// crash (mpvpaper has a known hotplug segfault): respawn the
|
||||
// whole set after a beat rather than reasoning per-output.
|
||||
if (root.active && !playerRespawn.running)
|
||||
playerRespawn.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOutputsChanged: if (root.active) playerRespawn.restart()
|
||||
|
||||
Process {
|
||||
id: hyprpaperControl
|
||||
}
|
||||
|
||||
Process {
|
||||
id: frameProc
|
||||
}
|
||||
|
||||
// ── Pause, over mpv's JSON IPC ──────────────────────────────────────────
|
||||
|
||||
onPausedChanged: pauseSync.restart()
|
||||
|
||||
// The sockets appear a moment after mpvpaper starts; a short retry beats
|
||||
// ordering ceremony. Each sync writes the current desired state to every
|
||||
// output's socket and disconnects.
|
||||
Timer {
|
||||
id: pauseSync
|
||||
interval: 600
|
||||
onTriggered: root.pushPauseState()
|
||||
}
|
||||
|
||||
function pushPauseState(): void {
|
||||
if (!root.active)
|
||||
return;
|
||||
for (const output of root.outputs) {
|
||||
const socket = pauseSocketComponent.createObject(root, {
|
||||
socketPath: root.socketFor(output),
|
||||
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
|
||||
});
|
||||
socket.connected = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pauseSocketComponent
|
||||
|
||||
Socket {
|
||||
id: pauseSocket
|
||||
property string payload: ""
|
||||
path: socketPath
|
||||
property string socketPath: ""
|
||||
onConnectionStateChanged: {
|
||||
if (connected) {
|
||||
write(payload);
|
||||
flush();
|
||||
Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); });
|
||||
}
|
||||
}
|
||||
onError: pauseSocket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause(): void {
|
||||
root.manuallyPaused = !root.manuallyPaused;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.rescan()
|
||||
}
|
||||
@@ -126,8 +126,16 @@ Singleton {
|
||||
id: restore
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
if (root.startupRestoreEnabled)
|
||||
root.applyCurrentPolicy(false);
|
||||
if (!root.startupRestoreEnabled)
|
||||
return;
|
||||
// A stored video wallpaper restores through mpvpaper; handing its
|
||||
// path to the hyprpaper transaction would only fail validation.
|
||||
if (DesktopPreferences.get("wallpaperMode") === "single"
|
||||
&& VideoWallpaper.isVideo(root.configured)) {
|
||||
VideoWallpaper.start(root.configured);
|
||||
return;
|
||||
}
|
||||
root.applyCurrentPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +205,10 @@ Singleton {
|
||||
function currentPolicy(): var {
|
||||
return {
|
||||
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
|
||||
globalPath: root.configured === "" ? root.shippedPath : root.configured,
|
||||
// A stored video path cannot serve as a hyprpaper still; policies
|
||||
// built while one is stored fall back to the shipped image.
|
||||
globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured)
|
||||
? root.shippedPath : root.configured,
|
||||
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
|
||||
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
|
||||
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
|
||||
@@ -335,6 +346,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function applyCurrentPolicy(persist: bool): bool {
|
||||
// While mpvpaper holds the background, hyprpaper has nothing to apply
|
||||
// — hotplug is VideoWallpaper's problem and it respawns per output.
|
||||
if (VideoWallpaper.active)
|
||||
return true;
|
||||
return root.applyPolicy(root.currentPolicy(), persist === true, false);
|
||||
}
|
||||
|
||||
@@ -374,6 +389,19 @@ Singleton {
|
||||
}
|
||||
|
||||
function setSingle(path: string): bool {
|
||||
// A video routes to mpvpaper instead of hyprpaper; it still rides
|
||||
// wallpaperPath so restore, backup, and the picker's "current" ring
|
||||
// treat both kinds the same.
|
||||
if (VideoWallpaper.isVideo(path)) {
|
||||
if (!VideoWallpaper.start(path)) {
|
||||
root.lastError = VideoWallpaper.lastError;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperMode", "single");
|
||||
DesktopPreferences.set("wallpaperPath", path);
|
||||
return true;
|
||||
}
|
||||
const effectivePath = path === "" ? root.shippedPath : path;
|
||||
const allowed = root.candidates([]);
|
||||
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||
@@ -384,20 +412,53 @@ Singleton {
|
||||
policy.mode = "single";
|
||||
policy.globalPath = effectivePath;
|
||||
policy.slideshowPath = effectivePath;
|
||||
// Returning from a video: stop mpvpaper first, then give hyprpaper's
|
||||
// service a beat to come back before the transaction talks to it.
|
||||
if (VideoWallpaper.active) {
|
||||
VideoWallpaper.stop();
|
||||
root.pendingStillPolicy = policy;
|
||||
stillAfterVideo.restart();
|
||||
return true;
|
||||
}
|
||||
return root.applyPolicy(policy, true, false);
|
||||
}
|
||||
|
||||
property var pendingStillPolicy: null
|
||||
|
||||
Timer {
|
||||
id: stillAfterVideo
|
||||
interval: 900
|
||||
onTriggered: {
|
||||
if (root.pendingStillPolicy) {
|
||||
root.applyPolicy(root.pendingStillPolicy, true, false);
|
||||
root.pendingStillPolicy = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode: string): bool {
|
||||
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
||||
return false;
|
||||
const policy = root.currentPolicy();
|
||||
policy.mode = mode;
|
||||
// Slideshow and per-display are hyprpaper's modes; a stored video path
|
||||
// cannot serve as their global still, so fall back to the shipped one.
|
||||
if (VideoWallpaper.isVideo(policy.globalPath)) {
|
||||
policy.globalPath = root.shippedPath;
|
||||
policy.slideshowPath = root.shippedPath;
|
||||
}
|
||||
if (mode === "slideshow") {
|
||||
const collection = WallpaperPolicy.validCollection(
|
||||
policy.collection, root.candidates([]));
|
||||
if (!collection.includes(policy.slideshowPath))
|
||||
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||
}
|
||||
if (VideoWallpaper.active) {
|
||||
VideoWallpaper.stop();
|
||||
root.pendingStillPolicy = policy;
|
||||
stillAfterVideo.restart();
|
||||
return true;
|
||||
}
|
||||
return root.applyPolicy(policy, true, false);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user