Make the accent colour choosable
Phase 4, first slice. Theme.qml hardcoded the Prism pair, so the one thing that carries every state meaning in the desktop -- focused, active, on -- was the one thing nobody could change. 74 files read Theme.accent, so making it a setting moves all of them at once. Named accents rather than a colour picker, which is the design decision worth defending. One hex cannot serve both schemes: a colour legible on the Moon background is usually illegible on the Day one, and a picker that lets someone build an unreadable desktop is not a feature. So each name carries a curated pair per scheme, and every one of the sixteen resulting colours measures at least 3:1 against the ground it sits on -- checked, not assumed. It is also GNOME's model, which is the parity being chased. The focused window border comes with it, and only because the ownership rule made that safe. ColorScheme owns the inactive border as a scheme-relative contrast role; the focused Prism border is the accent role owned by the theme. Writing it from the accent would have been reckless before that boundary existed, since a scheme change would have erased the user's choice. Both borders are now pushed together, because each accent carries separate light and dark pairs, so switching schemes must restate the focused border too. The gradient is written as a Lua table, not a string. The string form carries only one stop, and passing two as a string is accepted and silently keeps the previous value. Swatches are drawn as the gradient they produce rather than as flat dots, because the gradient is what is being chosen. Each carries its name permanently rather than in a tooltip: telling swatches apart by colour is precisely what someone with a colour vision deficiency cannot do, which is also why the palette is named in the first place. Verified end to end by switching to rose and watching the compositor report eeff757f/eec099ff, then reverting. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -871,6 +871,26 @@ Singleton {
|
|||||||
{ value: "light", label: "Light" }
|
{ 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 ─────────────────────────────────────────────
|
// ── Application themes ─────────────────────────────────────────────
|
||||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||||
|
|||||||
@@ -42,13 +42,41 @@ Singleton {
|
|||||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||||
|
|
||||||
// The pair. `accent` is the primary and carries every state meaning
|
// ── The accent ──────────────────────────────────────────────────────────
|
||||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
//
|
||||||
// theme — it never appears alone, only as the far end of a gradient. That
|
// `accent` is the primary and carries every state meaning (focused, active,
|
||||||
// restraint is the whole point: the two colours meeting is the signature,
|
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||||
// so the pink stops being special the moment it's used as a flat fill.
|
// gradient. That restraint is the whole point: the two colours meeting is
|
||||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
// the signature, so the second colour stops being special the moment it is
|
||||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
// 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 accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,13 @@ SettingsPage {
|
|||||||
? ColorScheme.lastError
|
? 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."
|
: "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 {
|
SettingsCard {
|
||||||
|
|||||||
@@ -61,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
|
|||||||
RegionPage 1.0 RegionPage.qml
|
RegionPage 1.0 RegionPage.qml
|
||||||
SearchPicker 1.0 SearchPicker.qml
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||||
|
AccentPicker 1.0 AccentPicker.qml
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ Singleton {
|
|||||||
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||||
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||||
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
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: ""
|
property string lastError: ""
|
||||||
|
|
||||||
// Applied one command at a time: Process runs a single command, and several
|
// 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]
|
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||||
];
|
];
|
||||||
|
|
||||||
// Unfocused window borders need scheme-relative contrast. The focused
|
// Unfocused window borders need scheme-relative contrast, and stay this
|
||||||
// Prism border is deliberately owned by the accent/theme layer.
|
// 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",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
`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
|
// Applications that predate org.freedesktop.appearance and carry their
|
||||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||||
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||||
|
|||||||
Reference in New Issue
Block a user