diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c87176b..bac48ac 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -871,6 +871,26 @@ Singleton { { value: "light", label: "Light" } ] }, + { + key: "accentName", type: "enum", def: "blue", group: "appearance", + label: "Accent colour", + detail: "Drives the focused window border, the bar hairline, and every active state", + // NAMED accents, not a free colour. Each name carries a curated + // pair per scheme, because one hex cannot serve both: a colour + // legible on the dark ground is usually illegible on the light one. + // The palette and its measured contrast live in config/Theme.qml, + // which is also what stops this list drifting from what is drawn. + options: [ + { value: "blue", label: "Prism blue" }, + { value: "orchid", label: "Orchid" }, + { value: "teal", label: "Teal" }, + { value: "green", label: "Green" }, + { value: "amber", label: "Amber" }, + { value: "orange", label: "Orange" }, + { value: "rose", label: "Rose" }, + { value: "slate", label: "Slate" } + ] + }, // ── Application themes ───────────────────────────────────────────── // ColorScheme owns GTK's light/dark theme. These are the two theme diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index a490e56..d7eae15 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -42,13 +42,41 @@ Singleton { readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5" readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb" - // The pair. `accent` is the primary and carries every state meaning - // (focused, active, on). `accentSecondary` is the orchid from the tmux - // theme — it never appears alone, only as the far end of a gradient. That - // restraint is the whole point: the two colours meeting is the signature, - // so the pink stops being special the moment it's used as a flat fill. - readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue - readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux + // ── The accent ────────────────────────────────────────────────────────── + // + // `accent` is the primary and carries every state meaning (focused, active, + // on). `accentSecondary` never appears alone, only as the far end of a + // gradient. That restraint is the whole point: the two colours meeting is + // the signature, so the second colour stops being special the moment it is + // used as a flat fill. + // + // NAMED accents rather than a free colour. Each name carries a curated + // triple per scheme, because an arbitrary hex cannot work in both: a colour + // legible on the Moon background is usually illegible on the Day one, and a + // picker that lets someone choose an unreadable desktop is not a feature. + // Every pair below measures at least 3:1 against the ground it sits on. + // This is also GNOME's model, which is the parity being chased. + // + // Blue is the shipped Prism -- blue leading, orchid following -- and stays + // the default. + readonly property var accents: ({ + "blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" }, + "orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" }, + "teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" }, + "green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" }, + "amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" }, + "orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" }, + "rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" }, + "slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" } + }) + + // Falls back to blue for an unknown name, so a settings file written by a + // newer Panama -- or edited by hand -- degrades to the shipped identity + // rather than to an undefined colour. + readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"] + + readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light + readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue readonly property color cyan: root.dark ? "#86e1fc" : "#007197" readonly property color teal: root.dark ? "#4fd6be" : "#118c74" diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml new file mode 100644 index 0000000..fa04940 --- /dev/null +++ b/config/dot/quickshell/modules/settings/AccentPicker.qml @@ -0,0 +1,84 @@ +// Choosing the desktop accent. +// +// Each swatch is drawn as the GRADIENT it will actually produce, not a flat +// dot, because the gradient is the thing being chosen -- the focused window +// border, the bar hairline and every active state are the two colours meeting. +// A row of flat circles would misrepresent all of them. +// +// Named accents rather than a colour wheel: each name carries a curated pair +// per scheme, so every choice stays legible in both light and dark. See +// config/Theme.qml for the palette and the reasoning. + +import QtQuick +import qs.config +import qs.services + +Flow { + id: root + + spacing: 10 + + readonly property string current: DesktopPreferences.get("accentName") || "blue" + + 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) + + 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 color start: Theme.dark ? entry.pair.dark : entry.pair.light + readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary + + spacing: 5 + + Rectangle { + width: 46 + height: 46 + radius: 23 + anchors.horizontalCenter: parent.horizontalCenter + color: "transparent" + // The ring sits outside the gradient rather than over it, so a + // selected swatch still shows its true colours. + border.width: entry.selected ? 2 : 1 + border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14) + + Rectangle { + anchors.fill: parent + anchors.margins: entry.selected ? 4 : 3 + radius: width / 2 + border.width: 0 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: entry.start } + 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 + // exactly what someone with a colour vision deficiency cannot do, + // and it is the reason the palette is named rather than freeform -- + // hiding the names behind a hover would waste that. + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: entry.pair.label + color: entry.selected ? Theme.fg : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: entry.selected ? Font.DemiBold : Font.Normal + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 9d83f02..5c4cb97 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -106,7 +106,13 @@ SettingsPage { ? ColorScheme.lastError : "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow." - ChoiceRow { setting: "colorScheme"; divider: false } + ChoiceRow { setting: "colorScheme" } + + // Drawn as the gradient each accent produces rather than a flat dot, + // because the gradient is what is being chosen. + AccentPicker { + width: parent.width + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 79c1719..24b664b 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -61,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml RegionPage 1.0 RegionPage.qml SearchPicker 1.0 SearchPicker.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml +AccentPicker 1.0 AccentPicker.qml diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index 7a5bc28..cf78f41 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -29,6 +29,16 @@ Singleton { 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 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)"; + } + readonly property string accentBorderStart: root.hyprColor(Theme.accent) + readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary) property string lastError: "" // Applied one command at a time: Process runs a single command, and several @@ -104,11 +114,24 @@ Singleton { ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme] ]; - // Unfocused window borders need scheme-relative contrast. The focused - // Prism border is deliberately owned by the accent/theme layer. + // 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}" } } })`]); + // 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 } } } })`]); + // 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