diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index 53b563b..423a13b 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -2,7 +2,7 @@ -- Look and feel -- Tokyo Night Moon -- -- Colours here must stay in sync with quickshell/config/Theme.qml. --- accent #82aaff borders / focus +-- accent user-selectable, see `accents` below -- blue (#82aaff) ships -- bg #222436 base -- -- Performance note: every animation below is event-driven. Nothing uses the @@ -14,6 +14,30 @@ local prefs = require("prefs") +-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML +-- singleton, so the hex pairs are restated here -- just the four hex strings +-- each named accent needs, not the labels, which stay UI-only. +local accents = { + blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" }, + orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" }, + teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" }, + green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" }, + amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" }, + orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" }, + rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" }, + slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" }, +} + +-- The accent pair a fresh session or `hyprctl reload` starts from. +-- services/ColorScheme.qml overwrites this live, from the same Theme.accents +-- data, once the shell settles (~1.2s after startup -- see its `settle` +-- Timer). This table exists only so the compositor is never observably blue +-- for a non-blue accent during the gap before that first live apply. +local accentScheme = prefs.get("colorScheme", "dark") +local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue +local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark +local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary + hl.config({ general = { gaps_in = prefs.get("gapsIn", 5), @@ -22,11 +46,13 @@ hl.config({ border_size = prefs.get("borderSize", 2), col = { - -- The focused accent role: blue leads, orchid follows, on a - -- diagonal so the pair is visible on both a tall and a wide - -- window. ColorScheme never writes this role; a future accent - -- picker can own it without fighting light/dark mode. - active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, + -- The focused accent role, on a diagonal so the pair is visible + -- on both a tall and a wide window. Driven by the chosen + -- accentName (see the `accents` table above); services/ + -- ColorScheme.qml applies the same values live, and restates them + -- from Theme.accent/Theme.accentSecondary on every scheme change + -- too, since each accent carries a separate pair per scheme. + active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 }, -- The neutral inactive role follows the colour scheme because a -- dark neutral disappears against a light desktop. -- services/ColorScheme.qml applies the same values live; this is @@ -104,11 +130,13 @@ hl.config({ -- New in 0.56. Kept deliberately faint: in this direction the gradient -- border is the signature, and a strong halo would compete with it. -- This is just enough to lift the focused window off the wallpaper. + -- Derived from the same accent as active_border above, not hardcoded, + -- so the halo never disagrees with the border it surrounds. glow = { enabled = prefs.get("glowEnabled", true), range = prefs.get("glowRange", 8), render_power = 2, - color = "rgba(82aaff33)", + color = "rgba(" .. accentStart .. "33)", color_inactive = "rgba(00000000)", }, diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml index fa04940..a84ea42 100644 --- a/config/dot/quickshell/modules/settings/AccentPicker.qml +++ b/config/dot/quickshell/modules/settings/AccentPicker.qml @@ -20,23 +20,45 @@ Flow { readonly property string current: DesktopPreferences.get("accentName") || "blue" + // The schema's own option list, not Theme.accents directly -- two lists + // hand-kept in sync is how they drift. This is the same pattern + // ChoiceRow.qml uses for every other enum row. + readonly property var spec: PreferenceSchema.spec("accentName") + readonly property var options: root.spec && root.spec.options ? root.spec.options : [] + Repeater { - // Object key order is insertion order here, so the palette's own order - // is what the user sees; blue first because it is what Panama ships. - model: Object.keys(Theme.accents) + // The schema's option order is the palette's order, so blue is first + // because it is what Panama ships. + model: root.options Column { id: entry required property var modelData - readonly property var pair: Theme.accents[entry.modelData] - readonly property bool selected: entry.modelData === root.current + readonly property string name: entry.modelData.value + readonly property var pair: Theme.accents[entry.name] + readonly property bool selected: entry.name === root.current readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary spacing: 5 + // The hit target is the whole swatch+label unit, not just the + // 46px circle: the label exists specifically so someone with a + // colour vision deficiency can identify an accent without it, and + // a label that cannot itself be tapped defeats that. + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: { + if (!SystemSettings.commitPreference("accentName", entry.name)) + console.warn("AccentPicker: commitPreference rejected accent", entry.name); + } + } + Rectangle { width: 46 height: 46 @@ -59,12 +81,6 @@ Flow { GradientStop { position: 1.0; color: entry.end } } } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: SystemSettings.commitPreference("accentName", entry.modelData) - } } // Always shown, not a tooltip. Telling swatches apart by colour is @@ -73,7 +89,7 @@ Flow { // hiding the names behind a hover would waste that. Text { anchors.horizontalCenter: parent.horizontalCenter - text: entry.pair.label + text: entry.modelData.label color: entry.selected ? Theme.fg : Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index ea50bfe..884688d 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -96,9 +96,11 @@ reason and an update to `tests/quickshell/settings-ownership-contract.sh`. Window border colour follows the same ownership rule. The inactive border is a **scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain neutral contrast in light and dark modes. The focused Prism border is the -accent role owned by the visual theme (and, eventually, an accent picker). -`ColorScheme.qml` must never write the focused border, so changing schemes -cannot erase a user-selected accent. +**accent role**, driven by the chosen `accentName` and also owned by +`ColorScheme.qml`: each accent carries a separate pair for light and dark, so +`ColorScheme.qml` restates the focused border alongside the inactive one on +every scheme change, rather than leaving a scheme flip to erase a +user-selected accent. ## The rows diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 9e3350b..4d4a837 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Generates Panama's hyprlock configuration into the state directory. The -# tracked config is never rewritten and remains the fallback if generation -# fails, so a malformed preference can never leave the session without a -# working locker. +# gitignored fallback config (rendered from the tracked .template at link +# time) is never rewritten by this script and remains the fallback if +# generation fails, so a malformed preference can never leave the session +# without a working locker. set -euo pipefail @@ -78,6 +79,31 @@ valid_path() { [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]] } +# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by +# hand -- there is no shared source between QML and a shell script), primary +# hue per scheme only: the lock screen shows a flat focus ring, not the +# two-stop gradient the window border does. Falls back to blue for an unknown +# name, matching Theme.accentPair's own fallback. +accent_hex() { + local name="$1" scheme="$2" + case "$name" in + orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;; + teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;; + green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;; + amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;; + orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;; + rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;; + slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;; + *) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;; + esac +} + +# hyprlock wants "R, G, B" decimal, not hex. +hex_to_rgb() { + local hex="$1" + printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}" +} + load_preferences() { if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then settings_valid=true @@ -99,6 +125,12 @@ load_preferences() { color_scheme="$(read_string colorScheme dark)" [[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark + accent_name="$(read_string accentName blue)" + case "$accent_name" in + blue|orchid|teal|green|amber|orange|rose|slate) ;; + *) accent_name=blue ;; + esac + wallpaper_mode="$(read_string wallpaperMode single)" [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ || wallpaper_mode=single @@ -121,8 +153,6 @@ load_preferences() { background_color='rgba(225, 226, 231, 1.0)' foreground_color='rgba(55, 96, 191, 1.0)' dim_color='rgba(97, 114, 176, 1.0)' - accent_color='rgba(46, 125, 233, 1.0)' - accent_ring_color='rgba(46, 125, 233, 0.9)' error_color='rgba(245, 42, 101, 1.0)' field_color='rgba(208, 213, 227, 0.85)' dim_hex='6172b0' @@ -131,13 +161,18 @@ load_preferences() { background_color='rgba(34, 36, 54, 1.0)' foreground_color='rgba(200, 211, 245, 1.0)' dim_color='rgba(130, 139, 184, 1.0)' - accent_color='rgba(130, 170, 255, 1.0)' - accent_ring_color='rgba(130, 170, 255, 0.9)' error_color='rgba(255, 117, 127, 1.0)' field_color='rgba(46, 47, 61, 0.85)' dim_hex='828bb8' error_hex='ff757f' fi + + # The focus ring is the accent role, not a scheme-relative one: it follows + # accentName the same way ColorScheme.qml's active_border does, and needs + # both the accent and the scheme since each accent carries a separate pair. + accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")" + accent_color="rgba($accent_rgb, 1.0)" + accent_ring_color="rgba($accent_rgb, 0.9)" } monitor_names() { diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index a6fc474..5fc2e86 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -16,21 +16,61 @@ # is authoritative for GTK3 applications. Pinned to dark, it contradicted the # scheme in light mode, so it is generated from a template here instead. # -# panama-theme-apps dark|light +# panama-theme-apps dark|light [accent] # # kitty gets it twice: the generated include file so terminals opened later # start correct, and a live `set-colors` over its control socket so terminals # already open change now. Without the second, a scheme change appears to do # nothing until you open a new window. +# +# kitty's active_border_color and hyprlock's focus ring are also the accent +# role, not just the scheme -- see accent_hex() below, which mirrors the same +# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason. set -euo pipefail scheme="${1:-dark}" case "$scheme" in dark|light) ;; - *) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; + *) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;; esac +# Falls back to blue for an unknown or omitted name, matching Theme.qml's own +# accentPair fallback -- so a caller that only ever knew about scheme (a stale +# ColorScheme.qml, a manual invocation) still gets a real accent instead of an +# empty string reaching sed. +accent="${2:-blue}" +case "$accent" in + blue|orchid|teal|green|amber|orange|rose|slate) ;; + *) accent=blue ;; +esac + +# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by +# hand -- there is no shared source between QML and a shell script), primary +# hue per scheme only: both consumers below draw a flat colour, not the +# two-stop gradient the window border does. +accent_hex() { + local name="$1" scheme="$2" + case "$name" in + orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;; + teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;; + green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;; + amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;; + orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;; + rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;; + slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;; + *) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;; + esac +} + +# hyprlock wants "R, G, B" decimal, not hex. +hex_to_rgb() { + local hex="$1" + printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}" +} + +accent_border_hex="$(accent_hex "$accent" "$scheme")" + # ── hyprlock ───────────────────────────────────────────────────────────────── # The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock || # hyprlock`), so it reads this file each time and needs no restart. @@ -45,7 +85,6 @@ lock_template="$lock_dir/hyprlock.conf.template" if [[ "$scheme" == "light" ]]; then lock_fg="55, 96, 191" # #3760bf lock_muted="97, 114, 176" # #6172b0 - lock_accent="46, 125, 233" # #2e7de9 lock_error="245, 42, 101" # #f52a65 lock_bg="225, 226, 231" # #e1e2e7 lock_field="208, 213, 227" # #d0d5e3 @@ -54,7 +93,6 @@ if [[ "$scheme" == "light" ]]; then else lock_fg="200, 211, 245" # #c8d3f5 lock_muted="130, 139, 184" # #828bb8 - lock_accent="130, 170, 255" # #82aaff lock_error="255, 117, 127" # #ff757f lock_bg="34, 36, 54" # #222436 lock_field="46, 47, 61" # #2e2f3d @@ -62,6 +100,10 @@ else lock_error_hex="ff757f" fi +# The focus ring is the accent role, not a scheme-relative one -- follows +# accentName the same way scripts/panama-lock's own generator does. +lock_accent="$(hex_to_rgb "$accent_border_hex")" + status_hyprlock="skipped" if [[ -r "$lock_template" ]]; then # Written atomically: a lock triggered mid-write would otherwise read a @@ -169,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf" status_kitty="skipped" if [[ -r "$theme_file" ]]; then # Written atomically: kitty may read this while a new window is starting. + # The accent override is appended after the copied theme rather than + # edited into the tracked theme file: kitty applies settings top to + # bottom, so a later active_border_color line wins, and this file is + # itself generated -- the tracked per-scheme theme stays scheme-only. if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \ + && printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \ && mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then status_kitty="written" + else + rm -f "$kitty_dir/current-theme.conf.tmp" fi # Live-apply to running terminals. kitty appends its PID to the socket name @@ -182,7 +231,8 @@ if [[ -r "$theme_file" ]]; then applied=0 while read -r socket; do [[ -n "$socket" ]] || continue - if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then + if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \ + && kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then applied=$((applied + 1)) fi done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u) diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index cf78f41..7794481 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -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); } diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index d95a683..5cac5a0 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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", diff --git a/tests/quickshell/lock-screen-helper-contract.sh b/tests/quickshell/lock-screen-helper-contract.sh index 5506e5f..6b12c22 100755 --- a/tests/quickshell/lock-screen-helper-contract.sh +++ b/tests/quickshell/lock-screen-helper-contract.sh @@ -69,7 +69,7 @@ assert_schema_entry() { } [[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable' -for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do +for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty accentName; do assert_schema_entry "$key" done @@ -112,6 +112,25 @@ if rg -q '^label \{' "$generated"; then fail 'hidden clock, date, and user labels were still generated' fi +# ── The focus ring follows the chosen accent, not just the scheme ─────────── +# A pinned blue literal only ever proves the DEFAULT accent renders correctly, +# not that the helper actually reads accentName. Orchid is picked because its +# hex is nowhere close to blue's in either scheme, so a helper that quietly +# ignored accentName and kept emitting blue would be caught here. +write_settings '{"accentName":"orchid","colorScheme":"dark"}' +run_helper generate +rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring' +rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success colour' + +write_settings '{"accentName":"orchid","colorScheme":"light"}' +run_helper generate +rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring' +rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success colour' + +write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}' +run_helper generate +rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue' + write_settings '{ "lockBackgroundMode":"wallpaper", "wallpaperMode":"per-monitor", diff --git a/tests/quickshell/settings-ownership-contract.sh b/tests/quickshell/settings-ownership-contract.sh index e4c2152..9a8eba2 100755 --- a/tests/quickshell/settings-ownership-contract.sh +++ b/tests/quickshell/settings-ownership-contract.sh @@ -2,7 +2,9 @@ # A setting has one schema-routed owner. A second page may mirror it only when # this contract names the exact owner and mirror set. The same ownership rule -# keeps colour scheme propagation away from the focused Prism border. +# says who writes each half of the window border: ColorScheme.qml owns the +# neutral inactive role AND the accent-derived focused role, restating both +# together on every scheme or accent change. set -euo pipefail @@ -124,8 +126,28 @@ if dark.group(1) not in looks or light.group(1) not in looks: raise SystemExit("Hyprland startup values disagree with the live scheme roles") without_comments = re.sub(r"//.*", "", scheme) -if re.search(r"(?