Finish threading the accent colour through the whole desktop

The recent accent-colour setting only reached part of the desktop.
looks.lua still hardcoded the focused-window border and glow to blue,
so any hyprctl reload -- or every fresh session, for about a second --
reverted a chosen accent; it now reads accentName the same way it
already read colorScheme. The lock screen and terminal stayed blue
regardless of the chosen accent despite the setting's own description
claiming otherwise; panama-lock and panama-theme-apps now resolve and
apply the real accent.

AccentPicker built its swatch model from Theme.accents directly
instead of the schema's own options list, so the two could drift
silently; switched it to read the schema. Its hit target only covered
the swatch, not the name label added specifically for colour-vision
accessibility -- extended to the whole row. Settings search had no
route for the "appearance" group, so searching for the accent or
colour scheme landed on Home.

ColorScheme's hex-to-Hyprland helper assumed 6-digit colours and would
silently corrupt a future translucent one; fixed it to read from the
end of the string instead of the start. An accent-only change no
longer reruns the full colour-scheme pipeline. The gradient it builds
for the focused border now goes through SystemSettings' existing
serialiser instead of a second, under-escaped copy of the same logic.

The settings-ownership contract test enforced the old rule that
ColorScheme must never touch the focused border; updated it to verify
the real, intended rule instead of contradicting the code.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
This commit is contained in:
Gabriel Brown
2026-08-18 21:23:16 -04:00
parent 8b59b78d9f
commit 9ba224d776
9 changed files with 301 additions and 83 deletions
+90 -45
View File
@@ -34,13 +34,23 @@ Singleton {
// for the Prism gradient; Theme owns which colours, this owns the form the
// compositor wants them in.
function hyprColor(value: var): string {
// Qt gives "#rrggbb"; Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(0, 6) + "ee)";
// Qt gives "#rrggbb" -- or "#aarrggbb" if the colour 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)";
}
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
property string lastError: ""
// 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.
property bool appliedDark: false
property string appliedAccentName: ""
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
@@ -75,7 +85,7 @@ Singleton {
Timer {
id: settle
interval: 1200
onTriggered: root.apply()
onTriggered: root.apply(true)
}
Connections {
@@ -86,57 +96,92 @@ Singleton {
Timer {
id: coalesce
interval: 250
onTriggered: root.apply()
onTriggered: root.apply(false)
}
function apply(): void {
// `force` pushes every step regardless of what moved -- startup needs
// that, because gsettings and the compositor keep their own state and
// have no way to know a previous Quickshell session already told them the
// answer. Everywhere else this reacts to ANY preference changing (see
// onRevisionChanged above), so most calls have nothing to do with either
// scheme or accent; only the steps whose inputs actually moved since the
// last push run, which keeps sampling accent swatches from also rewriting
// gsettings and re-running the whole app-theming script on every sample.
function apply(force: bool): void {
root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light";
const accentName = DesktopPreferences.get("accentName") || "blue";
const schemeChanged = force || root.dark !== root.appliedDark;
const accentChanged = force || accentName !== root.appliedAccentName;
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
// broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
if (!schemeChanged && !accentChanged)
return;
const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
const commands = [];
// Unfocused window borders need scheme-relative contrast, and stay this
// service's to own. The FOCUSED border is the accent role and belongs to
// the theme -- see modules/settings/README.md -- so it is written from
// the chosen accent rather than from the scheme.
//
// Both are pushed together because both change when the scheme flips:
// each accent carries a separate pair for light and dark, so switching
// schemes must restate the focused border too, not only the neutral one.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
if (schemeChanged) {
const scheme = root.dark ? "prefer-dark" : "prefer-light";
// A two-stop gradient at the shipped angle. Written as a Lua TABLE: the
// string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently keeps
// the previous value.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = { colors = { "${root.accentBorderStart}", "${root.accentBorderEnd}" }, angle = 115 } } } })`]);
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
// broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
// Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here.
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
// 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.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
}
if (schemeChanged || accentChanged) {
// 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
// scheme's other colours.
//
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
// the string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
// keeps the previous value. Multi-stop must be the table form --
// built by the same serialiseValue() SystemSettings uses for every
// other gradient, rather than a second hand-rolled copy of that
// escaping here.
const activeBorder = SystemSettings.serialiseValue({
colors: [root.accentBorderStart, root.accentBorderEnd],
angle: 115
});
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
// Applications that predate org.freedesktop.appearance and carry
// their own palettes -- terminals, chiefly. Everything that reads
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
// the gsettings write above and needs nothing here. The accent is
// passed through too: kitty's border and the hyprlock fallback
// template are also the accent role, not just the scheme -- so
// this still has to run on an accent-only change, even though the
// scheme-relative work it also does is then redundant.
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.enqueue(commands);
}
@@ -26,6 +26,7 @@ Singleton {
// entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({
"appearance": "appearance",
"clock": "appearance",
"vitals": "appearance",
"typography": "appearance",