From ed428e87c450ecb8a12f4fa65a60fd4a4da6ef09 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 19:33:39 -0400 Subject: [PATCH] Complete the Panama theme system --- .../quickshell/accent-controls-harness.qml | 41 ++ .../quickshell/config/PreferenceSchema.qml | 13 + config/dot/quickshell/config/Theme.qml | 33 +- .../modules/settings/AccentEditor.qml | 218 +++++++++ .../modules/settings/AccentPicker.qml | 28 +- .../modules/settings/AppearancePage.qml | 14 +- .../modules/settings/ThemeProfilePicker.qml | 123 +++++ config/dot/quickshell/modules/settings/qmldir | 2 + .../dot/quickshell/services/ColorScheme.qml | 8 + .../quickshell/services/SettingsSearch.qml | 5 +- .../quickshell/services/ThemeProfileModel.js | 419 ++++++++++++++++++ .../dot/quickshell/services/ThemeProfiles.qml | 108 +++++ .../dot/quickshell/theme-profiles-harness.qml | 40 ++ tests/quickshell/accent-controls-contract.sh | 133 ++++++ tests/quickshell/adwaita-accent-contract | 26 +- tests/quickshell/control-center-contract | 31 ++ tests/quickshell/theme-profiles-contract.sh | 169 +++++++ 17 files changed, 1370 insertions(+), 41 deletions(-) create mode 100644 config/dot/quickshell/accent-controls-harness.qml create mode 100644 config/dot/quickshell/modules/settings/AccentEditor.qml create mode 100644 config/dot/quickshell/modules/settings/ThemeProfilePicker.qml create mode 100644 config/dot/quickshell/services/ThemeProfileModel.js create mode 100644 config/dot/quickshell/services/ThemeProfiles.qml create mode 100644 config/dot/quickshell/theme-profiles-harness.qml create mode 100755 tests/quickshell/accent-controls-contract.sh create mode 100755 tests/quickshell/theme-profiles-contract.sh diff --git a/config/dot/quickshell/accent-controls-harness.qml b/config/dot/quickshell/accent-controls-harness.qml new file mode 100644 index 0000000..79e212c --- /dev/null +++ b/config/dot/quickshell/accent-controls-harness.qml @@ -0,0 +1,41 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.settings +import qs.services + +ShellRoot { + Item { + width: 680 + height: editor.implicitHeight + + AccentEditor { + id: editor + width: parent.width + } + + ThemeProfilePicker { + width: parent.width + visible: false + } + + AccentPicker { + width: parent.width + visible: false + } + } + + IpcHandler { + target: "accent-controls-test" + + function status(): string { + return JSON.stringify(ThemeProfiles.activeProfile); + } + + function adjust(target: string, channel: string, ratio: real): string { + editor.changeChannel(target, channel, ratio); + return status(); + } + } +} diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index e92e226..cd7b4fc 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -975,6 +975,19 @@ Singleton { { value: "slate", label: "Slate" } ] }, + { + key: "themeProfileId", type: "string", def: "moon", group: "appearance", + pattern: "^[a-z0-9][a-z0-9-]{0,63}$", + label: "Selected theme profile", + detail: "The shipped or saved theme currently applied to the desktop", + internal: true + }, + { + key: "themeProfiles", type: "json", def: [], group: "appearance", + label: "Saved theme profiles", + detail: "Named custom colour schemes and accent pairs", + internal: true + }, // ── 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 882da56..e54d8d1 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -14,6 +14,7 @@ import Quickshell // QtQuick is required even though nothing visual is declared here: `color` is a // QtQuick value type, and Qt.rgba() lives in its JS namespace. import QtQuick +import qs.services Singleton { id: root @@ -60,30 +61,20 @@ Singleton { // Blue is the shipped Prism -- blue leading, orchid following -- and stays // the default. // - // `gnome` is the nearest member of GNOME's own accent-color enum, which is - // a fixed list of nine we do not get to extend. It is what libadwaita + // The table itself lives in services/ThemeProfileModel.js so a curated + // accent and a custom profile are the same kind of record. Its `gnome` + // member is the nearest name in GNOME's own accent-color enum, which is a + // fixed list of nine we do not get to extend; it is what libadwaita // applications -- Files, Papers, Loupe -- are told to use, so choosing an // accent here recolors them too instead of leaving them in GNOME blue. - // Nearest by hue, not by name: "rose" maps to red rather than pink because - // it is the red role in this palette. - readonly property var accents: ({ - "blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" }, - "orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" }, - "teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" }, - "green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" }, - "amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" }, - "orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" }, - "rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" }, - "slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" } - }) + readonly property var accents: ThemeProfiles.curatedAccents + readonly property var activeProfile: ThemeProfiles.activeProfile - // 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 color. - 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 + // ThemeProfiles validates every persisted record before it can become + // active, so these bindings are both reactive and safe to expose as the + // shell-wide colour roles. + readonly property color accent: root.activeProfile.accent + readonly property color accentSecondary: root.activeProfile.secondary 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/AccentEditor.qml b/config/dot/quickshell/modules/settings/AccentEditor.qml new file mode 100644 index 0000000..93ca53d --- /dev/null +++ b/config/dot/quickshell/modules/settings/AccentEditor.qml @@ -0,0 +1,218 @@ +// Advanced accent editing remains a labelled, keyboard-operable extension of +// the named fast path. Hue is always accompanied by saturation, value, a +// numeric readout, and the two-colour preview supplied by Appearance. + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.services +import qs.widgets +import "../../services/ThemeProfileModel.js" as ThemeProfileModel + +Column { + id: editor + + width: parent ? parent.width : 620 + spacing: 0 + + property string pickerTarget: "primary" + property string lastError: "" + + readonly property var primaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 }) + readonly property var secondaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 }) + + function changeChannel(target: string, channel: string, ratio: real): void { + const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv; + const next = { h: source.h, s: source.s, v: source.v }; + next[channel] = Math.round(Math.max(0, Math.min(1, ratio)) + * (channel === "h" ? 360 : 100)); + const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v); + const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent; + const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + function pick(target: string): void { + if (screenPicker.running) + return; + editor.pickerTarget = target; + editor.lastError = ""; + screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]); + } + + function acceptPicked(value: string): void { + const picked = String(value).trim().toLowerCase(); + if (ThemeProfileModel.hexToHsv(picked) === null) { + editor.lastError = "The sampled colour was not valid."; + return; + } + const primary = editor.pickerTarget === "primary" + ? picked : ThemeProfiles.activeProfile.accent; + const secondary = editor.pickerTarget === "secondary" + ? picked : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + component HsvRow: SettingRow { + id: root + + required property string target + required property string channel + required property real channelValue + required property real channelMaximum + property string suffix: "%" + + controlWidth: 280 + + Item { + id: keyboardSlider + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 270 + height: 32 + activeFocusOnTab: true + + Accessible.role: Accessible.Slider + Accessible.name: root.label + Accessible.description: Math.round(root.channelValue) + root.suffix + + ", range 0 to " + root.channelMaximum + Accessible.focusable: true + Accessible.focused: activeFocus + Accessible.onIncreaseAction: keyboardSlider.step(1) + Accessible.onDecreaseAction: keyboardSlider.step(-1) + + function step(direction: int): void { + const increment = root.channel === "h" ? 1 : 1; + const value = Math.max(0, Math.min(root.channelMaximum, + root.channelValue + direction * increment)); + editor.changeChannel(root.target, root.channel, value / root.channelMaximum); + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) { + keyboardSlider.step(-1); + event.accepted = true; + } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) { + keyboardSlider.step(1); + event.accepted = true; + } else if (event.key === Qt.Key_Home) { + editor.changeChannel(root.target, root.channel, 0); + event.accepted = true; + } else if (event.key === Qt.Key_End) { + editor.changeChannel(root.target, root.channel, 1); + event.accepted = true; + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: readout.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + height: 24 + radius: 9 + color: "transparent" + border.width: keyboardSlider.activeFocus ? 2 : 1 + border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08) + + ValueSlider { + anchors.fill: parent + anchors.margins: 4 + value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0 + onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio) + } + } + + Text { + id: readout + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 56 + horizontalAlignment: Text.AlignRight + text: Math.round(root.channelValue) + root.suffix + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + } + + HsvRow { + target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees" + } + HsvRow { + target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100 + label: "Primary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100 + label: "Primary value"; detail: "Brightness from black to full colour" + } + HsvRow { + target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient" + } + HsvRow { + target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100 + label: "Secondary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100 + label: "Secondary value"; detail: "Brightness from black to full colour" + } + + SettingRow { + label: "Pick colour from screen" + detail: editor.lastError !== "" + ? editor.lastError + : "Sample either end of the accent gradient with hyprpicker" + divider: false + controlWidth: 330 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + SettingsButton { + text: "Pick primary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("primary") + Keys.onReturnPressed: if (enabled) editor.pick("primary") + Keys.onSpacePressed: if (enabled) editor.pick("primary") + } + + SettingsButton { + text: "Pick secondary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("secondary") + Keys.onReturnPressed: if (enabled) editor.pick("secondary") + Keys.onSpacePressed: if (enabled) editor.pick("secondary") + } + } + } + + Process { + id: screenPicker + + stdout: StdioCollector { + onStreamFinished: editor.acceptPicked(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + editor.lastError = "Screen colour picking was cancelled or unavailable."; + } + } +} diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml index cb7ec8f..7784333 100644 --- a/config/dot/quickshell/modules/settings/AccentPicker.qml +++ b/config/dot/quickshell/modules/settings/AccentPicker.qml @@ -18,7 +18,7 @@ Flow { spacing: 10 - readonly property string current: DesktopPreferences.get("accentName") || "blue" + readonly property string current: ThemeProfiles.activeAccentName // 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 @@ -60,6 +60,8 @@ Flow { } Rectangle { + id: swatch + width: 46 height: 46 radius: 23 @@ -67,8 +69,25 @@ Flow { color: "transparent" // The ring sits outside the gradient rather than over it, so a // selected swatch still shows its true colors. - border.width: entry.selected ? 2 : 1 - border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14) + border.width: swatch.activeFocus || entry.selected ? 2 : 1 + border.color: swatch.activeFocus + ? Theme.accentSecondary + : (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)) + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: entry.pair.label + " accent" + Accessible.description: String(entry.start) + " to " + String(entry.end) + Accessible.focusable: true + Accessible.focused: activeFocus + + function choose(): void { + if (!ThemeProfiles.useCuratedAccent(entry.modelData)) + console.warn("AccentPicker: accent was rejected", entry.modelData); + } + + Keys.onReturnPressed: swatch.choose() + Keys.onSpacePressed: swatch.choose() Rectangle { anchors.fill: parent @@ -81,6 +100,9 @@ Flow { GradientStop { position: 1.0; color: entry.end } } } + + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: swatch.choose() } } // Always shown, not a tooltip. Telling swatches apart by color is diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 720171c..de6ab49 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -129,18 +129,26 @@ SettingsPage { SettingsCard { visible: root.tab === "theme" - title: "Color scheme" + // Not "Color scheme" any more: the card holds the saved themes as well + // as the scheme, and a theme carries both ends of the accent with it. + title: "Theme" subtitle: 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." + : "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent." - ChoiceRow { setting: "colorScheme" } + ThemeProfilePicker { + width: parent.width + } // Drawn as the gradient each accent produces rather than a flat dot, // because the gradient is what is being chosen. AccentPicker { width: parent.width } + + AccentEditor { + width: parent.width + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml new file mode 100644 index 0000000..db68d14 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml @@ -0,0 +1,123 @@ +// Named themes are the first layer of Appearance: shipped profiles stay +// immutable, while saved profiles can be selected or removed in place. + +import QtQuick +import qs.config +import qs.services + +Column { + id: root + + width: parent ? parent.width : 620 + spacing: 0 + + Repeater { + model: ThemeProfiles.profiles + + SettingRow { + id: profileRow + + required property var modelData + + label: profileRow.modelData.name + detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark") + + " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary + controlWidth: profileRow.modelData.shipped ? 88 : 170 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + SettingsButton { + id: useButton + text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use" + enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1) + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + } + + SettingsButton { + id: deleteButton + visible: !profileRow.modelData.shipped + text: "Delete" + activeFocusOnTab: visible + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + } + } + } + } + + SettingRow { + label: "Save current theme" + detail: "Stores this scheme and accent pair under a unique name" + divider: false + controlWidth: 292 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: 196 + height: 31 + radius: 8 + color: Theme.alpha(Theme.fg, 0.05) + border.width: nameInput.activeFocus ? 2 : 1 + border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12) + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + activeFocusOnTab: true + verticalAlignment: TextInput.AlignVCenter + maximumLength: 40 + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.35) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + Accessible.name: "Theme profile name" + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: nameInput.text === "" && !nameInput.activeFocus + text: "Theme name" + color: Theme.fgMuted + font: nameInput.font + } + + onAccepted: saveButton.save() + } + } + + SettingsButton { + id: saveButton + text: "Save" + tone: "accent" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 0 + border.color: activeFocus ? Theme.fg : "transparent" + + function save(): void { + if (ThemeProfiles.saveProfile(nameInput.text)) + nameInput.text = ""; + } + + onClicked: save() + Keys.onReturnPressed: save() + Keys.onSpacePressed: save() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 3275adf..183f6de 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml AccentPicker 1.0 AccentPicker.qml TextFieldRow 1.0 TextFieldRow.qml +AccentEditor 1.0 AccentEditor.qml +ThemeProfilePicker 1.0 ThemeProfilePicker.qml diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index bf95585..2cb8a22 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -93,6 +93,14 @@ Singleton { function onRevisionChanged(): void { coalesce.restart(); } } + // Profile selection and custom edits both update preferences, but binding + // directly to the effective profile also documents the in-shell ownership + // boundary and keeps this propagation reactive if that storage changes. + Connections { + target: ThemeProfiles + function onActiveProfileChanged(): void { coalesce.restart(); } + } + Timer { id: coalesce interval: 250 diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 553a257..c85d72e 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -136,7 +136,10 @@ Singleton { { label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }, { label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" }, { 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: "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: "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" } ] function pageFor(group: string): string { diff --git a/config/dot/quickshell/services/ThemeProfileModel.js b/config/dot/quickshell/services/ThemeProfileModel.js new file mode 100644 index 0000000..ed0a213 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfileModel.js @@ -0,0 +1,419 @@ +var MAX_NAME_LENGTH = 40; + +var SHIPPED = [ + { + id: "moon", + name: "Moon", + scheme: "dark", + accent: "#82aaff", + secondary: "#b172b0", + shipped: true + }, + { + id: "moon-rose", + name: "Moon Rose", + scheme: "dark", + accent: "#ff757f", + secondary: "#c099ff", + shipped: true + }, + { + id: "day", + name: "Day", + scheme: "light", + accent: "#2e7de9", + secondary: "#9854f1", + shipped: true + } +]; + +// The curated accents. `gnome` is the nearest member of GNOME's own +// accent-color enum, which is a fixed list of nine we do not get to extend. It +// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so +// choosing an accent here recolors them too instead of leaving them in GNOME +// blue. Nearest by hue, not by name: "rose" maps to red rather than pink +// because it is the red role in this palette. adwaita-accent-contract reads +// this table and fails when a member is missing or is not in GNOME's enum. +var CURATED = { + blue: { + dark: "#82aaff", darkSecondary: "#b172b0", + light: "#2e7de9", lightSecondary: "#9854f1", + label: "Prism blue", + gnome: "blue" + }, + orchid: { + dark: "#c099ff", darkSecondary: "#fca7ea", + light: "#7847bd", lightSecondary: "#9854f1", + label: "Orchid", + gnome: "purple" + }, + teal: { + dark: "#86e1fc", darkSecondary: "#82aaff", + light: "#007197", lightSecondary: "#2e7de9", + label: "Teal", + gnome: "teal" + }, + green: { + dark: "#c3e88d", darkSecondary: "#86e1fc", + light: "#587539", lightSecondary: "#007197", + label: "Green", + gnome: "green" + }, + amber: { + dark: "#ffc777", darkSecondary: "#ff966c", + light: "#8c6c3e", lightSecondary: "#b15c00", + label: "Amber", + gnome: "yellow" + }, + orange: { + dark: "#ff966c", darkSecondary: "#ff757f", + light: "#b15c00", lightSecondary: "#c64343", + label: "Orange", + gnome: "orange" + }, + rose: { + dark: "#ff757f", darkSecondary: "#c099ff", + light: "#f52a65", lightSecondary: "#9854f1", + label: "Rose", + gnome: "red" + }, + slate: { + dark: "#828bb8", darkSecondary: "#82aaff", + light: "#6172b0", lightSecondary: "#2e7de9", + label: "Slate", + gnome: "slate" + } +}; + +function copyProfile(profile) { + return { + id: profile.id, + name: profile.name, + scheme: profile.scheme, + accent: profile.accent, + secondary: profile.secondary, + shipped: profile.shipped === true + }; +} + +function shippedProfiles() { + return SHIPPED.map(copyProfile); +} + +function curatedAccents() { + var result = {}; + Object.keys(CURATED).forEach(function(name) { + result[name] = Object.assign({}, CURATED[name]); + }); + return result; +} + +function isScheme(value) { + return value === "dark" || value === "light"; +} + +function normalizedColor(value) { + var color = String(value || "").trim().toLowerCase(); + return /^#[0-9a-f]{6}$/.test(color) ? color : null; +} + +function normalizeStoredProfile(value) { + if (!value || typeof value !== "object" || value.shipped === true) + return null; + + var id = String(value.id || "").trim(); + var name = String(value.name || "").trim(); + var accent = normalizedColor(value.accent); + var secondary = normalizedColor(value.secondary); + if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id) + || name.length === 0 || name.length > MAX_NAME_LENGTH + || !isScheme(value.scheme) || !accent || !secondary) + return null; + + return { + id: id, + name: name, + scheme: value.scheme, + accent: accent, + secondary: secondary, + shipped: false + }; +} + +function validCustomProfiles(values) { + if (!Array.isArray(values)) + return []; + + var ids = {}; + var names = {}; + SHIPPED.forEach(function(profile) { + ids[profile.id] = true; + names[profile.name.toLowerCase()] = true; + }); + + var result = []; + values.forEach(function(value) { + var profile = normalizeStoredProfile(value); + if (!profile) + return; + var foldedName = profile.name.toLowerCase(); + if (ids[profile.id] || names[foldedName]) + return; + ids[profile.id] = true; + names[foldedName] = true; + result.push(profile); + }); + return result; +} + +function profileCatalog(values) { + return shippedProfiles().concat(validCustomProfiles(values)); +} + +function boundedName(value) { + var name = String(value || "").trim(); + if (!name) + name = "Custom theme"; + return name.slice(0, MAX_NAME_LENGTH); +} + +function uniqueName(value, profiles) { + var requested = boundedName(value); + var names = {}; + profiles.forEach(function(profile) { + names[profile.name.toLowerCase()] = true; + }); + if (!names[requested.toLowerCase()]) + return requested; + + for (var suffix = 2; suffix < 10000; suffix++) { + var ending = " " + suffix; + var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending; + if (!names[candidate.toLowerCase()]) + return candidate; + } + return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000"; +} + +function slug(value) { + var result = String(value || "").toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + .replace(/-+$/g, ""); + return result || "theme"; +} + +function uniqueId(name, profiles) { + var base = "custom-" + slug(name); + var ids = {}; + profiles.forEach(function(profile) { ids[profile.id] = true; }); + if (!ids[base]) + return base; + for (var suffix = 2; suffix < 10000; suffix++) { + var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix; + if (!ids[candidate]) + return candidate; + } + return base.slice(0, 52) + "-10000"; +} + +function createCustomProfile(values, input) { + var customs = validCustomProfiles(values); + var catalog = shippedProfiles().concat(customs); + var scheme = input && input.scheme; + var accent = normalizedColor(input && input.accent); + var secondary = normalizedColor(input && input.secondary); + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + var name = uniqueName(input && input.name, catalog); + var profile = { + id: uniqueId(name, catalog), + name: name, + scheme: scheme, + accent: accent, + secondary: secondary, + shipped: false + }; + return { profiles: customs.concat([profile]), profile: profile }; +} + +function findProfile(values, id) { + var catalog = profileCatalog(values); + for (var index = 0; index < catalog.length; index++) { + if (catalog[index].id === id) + return catalog[index]; + } + return null; +} + +function editProfile(values, selected, changes) { + var customs = validCustomProfiles(values); + 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; + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + if (selected.shipped === true) { + return createCustomProfile(customs, { + name: selected.name + " custom", + scheme: scheme, + accent: accent, + secondary: secondary + }); + } + + var stored = normalizeStoredProfile(selected); + if (!stored) + return { profiles: customs, profile: null }; + + var updated = Object.assign({}, stored, { + scheme: scheme, + accent: accent, + secondary: secondary + }); + var found = false; + var next = customs.map(function(profile) { + if (profile.id !== updated.id) + return profile; + found = true; + return updated; + }); + if (!found) + next.push(updated); + 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) + return { profiles: customs, removed: false }; + } + var next = customs.filter(function(profile) { return profile.id !== id; }); + return { profiles: next, removed: next.length !== customs.length }; +} + +function curatedPair(name, scheme) { + var entry = CURATED[name] || CURATED.blue; + if (scheme === "light") + return { accent: entry.light, secondary: entry.lightSecondary }; + return { accent: entry.dark, secondary: entry.darkSecondary }; +} + +function matchingShippedProfile(scheme, accent, secondary) { + var first = normalizedColor(accent); + var second = normalizedColor(secondary); + for (var index = 0; index < SHIPPED.length; index++) { + var profile = SHIPPED[index]; + if (profile.scheme === scheme && profile.accent === first && profile.secondary === second) + return copyProfile(profile); + } + return null; +} + +function curatedNameForProfile(profile) { + if (!profile || !isScheme(profile.scheme)) + return ""; + var accent = normalizedColor(profile.accent); + var secondary = normalizedColor(profile.secondary); + var names = Object.keys(CURATED); + for (var index = 0; index < names.length; index++) { + var name = names[index]; + var pair = curatedPair(name, profile.scheme); + if (pair.accent === accent && pair.secondary === secondary) + return name; + } + return ""; +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, Number(value))); +} + +function channelHex(value) { + var text = Math.round(value).toString(16); + return text.length < 2 ? "0" + text : text; +} + +function hsvToHex(hue, saturation, value) { + var h = Number(hue); + var s = Number(saturation); + var v = Number(value); + if (!isFinite(h) || !isFinite(s) || !isFinite(v)) + return null; + h = ((h % 360) + 360) % 360; + s = clamp(s, 0, 100) / 100; + v = clamp(v, 0, 100) / 100; + + var chroma = v * s; + var section = h / 60; + var x = chroma * (1 - Math.abs(section % 2 - 1)); + var red = 0; + var green = 0; + var blue = 0; + if (section < 1) { red = chroma; green = x; } + else if (section < 2) { red = x; green = chroma; } + else if (section < 3) { green = chroma; blue = x; } + else if (section < 4) { green = x; blue = chroma; } + else if (section < 5) { red = x; blue = chroma; } + else { red = chroma; blue = x; } + var match = v - chroma; + return "#" + channelHex((red + match) * 255) + + channelHex((green + match) * 255) + + channelHex((blue + match) * 255); +} + +function hexToHsv(value) { + var color = normalizedColor(value); + if (!color) + return null; + var red = parseInt(color.slice(1, 3), 16) / 255; + var green = parseInt(color.slice(3, 5), 16) / 255; + var blue = parseInt(color.slice(5, 7), 16) / 255; + var maximum = Math.max(red, green, blue); + var minimum = Math.min(red, green, blue); + var delta = maximum - minimum; + var hue = 0; + if (delta !== 0) { + if (maximum === red) + hue = 60 * (((green - blue) / delta) % 6); + else if (maximum === green) + hue = 60 * ((blue - red) / delta + 2); + else + hue = 60 * ((red - green) / delta + 4); + } + if (hue < 0) + hue += 360; + return { + h: Math.round(hue), + s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100), + v: Math.round(maximum * 100) + }; +} + +if (typeof module !== "undefined") { + module.exports = { + MAX_NAME_LENGTH: MAX_NAME_LENGTH, + shippedProfiles: shippedProfiles, + curatedAccents: curatedAccents, + validCustomProfiles: validCustomProfiles, + profileCatalog: profileCatalog, + createCustomProfile: createCustomProfile, + findProfile: findProfile, + editProfile: editProfile, + deleteProfile: deleteProfile, + curatedPair: curatedPair, + matchingShippedProfile: matchingShippedProfile, + curatedNameForProfile: curatedNameForProfile, + hsvToHex: hsvToHex, + hexToHsv: hexToHsv + }; +} diff --git a/config/dot/quickshell/services/ThemeProfiles.qml b/config/dot/quickshell/services/ThemeProfiles.qml new file mode 100644 index 0000000..24d7c48 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfiles.qml @@ -0,0 +1,108 @@ +pragma Singleton + +import Quickshell +import QtQuick +import qs.config +import "ThemeProfileModel.js" as ThemeProfileModel + +Singleton { + id: root + + readonly property var customProfiles: ThemeProfileModel.validCustomProfiles( + DesktopPreferences.get("themeProfiles")) + 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 activeProfile: ThemeProfileModel.findProfile( + root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0] + readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile( + root.activeProfile) + + property bool reconciling: false + + 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. + const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme); + const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id); + return schemeAccepted && profileAccepted; + } + + function selectProfile(id: string): bool { + return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id)); + } + + // 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 + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function saveProfile(name: string): bool { + const result = ThemeProfileModel.createCustomProfile(root.customProfiles, { + name: name, + scheme: root.activeProfile.scheme, + accent: root.activeProfile.accent, + secondary: root.activeProfile.secondary + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function deleteProfile(id: string): bool { + const result = ThemeProfileModel.deleteProfile(root.customProfiles, id); + 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 true; + } + + function useCuratedAccent(name: string): bool { + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + const pair = ThemeProfileModel.curatedPair(name, scheme); + const shipped = ThemeProfileModel.matchingShippedProfile( + scheme, pair.accent, pair.secondary); + const accepted = shipped + ? root.commitActive(shipped) + : root.setAccentPair(pair.accent, pair.secondary); + if (accepted) + SystemSettings.commitPreference("accentName", name); + return accepted; + } + + function reconcileScheme(): void { + if (root.reconciling) + return; + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + if (root.activeProfile.scheme === scheme) + return; + root.reconciling = true; + SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon"); + root.reconciling = false; + } + + Component.onCompleted: root.reconcileScheme() + + Connections { + target: DesktopPreferences + function onRevisionChanged(): void { root.reconcileScheme(); } + } +} diff --git a/config/dot/quickshell/theme-profiles-harness.qml b/config/dot/quickshell/theme-profiles-harness.qml new file mode 100644 index 0000000..b42e648 --- /dev/null +++ b/config/dot/quickshell/theme-profiles-harness.qml @@ -0,0 +1,40 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "theme-profiles-test" + + function status(): string { + return JSON.stringify({ + active: ThemeProfiles.activeProfile, + profiles: ThemeProfiles.profiles, + stored: DesktopPreferences.get("themeProfiles") + }); + } + + function select(id: string): bool { + return ThemeProfiles.selectProfile(id); + } + + function scheme(value: string): bool { + return SystemSettings.commitPreference("colorScheme", value); + } + + function edit(accent: string, secondary: string): bool { + return ThemeProfiles.setAccentPair(accent, secondary); + } + + function save(name: string): bool { + return ThemeProfiles.saveProfile(name); + } + + function remove(id: string): bool { + return ThemeProfiles.deleteProfile(id); + } + } +} diff --git a/tests/quickshell/accent-controls-contract.sh b/tests/quickshell/accent-controls-contract.sh new file mode 100755 index 0000000..9dc9108 --- /dev/null +++ b/tests/quickshell/accent-controls-contract.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +settings="$repo_dir/config/dot/quickshell/modules/settings" +theme="$repo_dir/config/dot/quickshell/config/Theme.qml" +scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml" +search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" + +fail() { + printf 'accent controls contract: %s\n' "$1" >&2 + exit 1 +} + +for component in AccentPicker AccentEditor ThemeProfilePicker; do + [[ -f "$settings/$component.qml" ]] || fail "$component is missing" + rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \ + || fail "$component is not registered in the settings module" +done + +for label in \ + 'Primary hue' 'Primary saturation' 'Primary value' \ + 'Secondary hue' 'Secondary saturation' 'Secondary value'; do + rg -Fq "$label" "$settings/AccentEditor.qml" \ + || fail "HSV control is missing the visible label $label" +done + +rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \ + || fail 'advanced accents do not reuse ValueSlider' +rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \ + || fail 'HSV controls do not expose slider semantics' +rg -Fq 'Accessible.name: root.label' "$settings/AccentEditor.qml" \ + || fail 'HSV controls are not programmatically labelled' +rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \ + || fail 'HSV controls do not expose their numeric value as a non-hue signal' +rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \ + || fail 'HSV controls cannot receive keyboard focus' +rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \ + || fail 'HSV controls cannot be adjusted from the keyboard' +rg -Fq 'activeFocus ? Theme.accentSecondary' "$settings/AccentEditor.qml" \ + || fail 'HSV controls have no visible keyboard focus treatment' + +rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \ + "$settings/AccentEditor.qml" \ + || fail 'screen picking does not use the validated hyprpicker hex invocation' +for target in 'Pick primary from screen' 'Pick secondary from screen'; do + rg -Fq "$target" "$settings/AccentEditor.qml" \ + || fail "screen picker action is missing $target" +done + +rg -Fq 'model: Object.keys(Theme.accents)' "$settings/AccentPicker.qml" \ + || fail 'curated swatches are no longer the fast path' +rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \ + || fail 'curated swatches do not select a profile-backed accent' +rg -Fq 'readonly property string current: ThemeProfiles.activeAccentName' "$settings/AccentPicker.qml" \ + || fail 'swatch selection does not follow the active profile' +rg -Fq 'Accessible.name: entry.pair.label + " accent"' "$settings/AccentPicker.qml" \ + || fail 'swatches rely on hue without a programmatic name' +rg -Fq 'activeFocusOnTab: true' "$settings/AccentPicker.qml" \ + || fail 'swatches cannot receive keyboard focus' +rg -Fq 'Keys.onReturnPressed:' "$settings/AccentPicker.qml" \ + || fail 'swatches cannot be selected from the keyboard' + +rg -Fq 'model: ThemeProfiles.profiles' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile picker does not show shipped and saved profiles' +rg -Fq 'ThemeProfiles.selectProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile switching is not wired' +rg -Fq 'ThemeProfiles.saveProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile saving is not wired' +rg -Fq 'maximumLength: 40' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile names are not visibly bounded to the model limit' +rg -Fq 'ThemeProfiles.deleteProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'custom profile deletion is not wired' +rg -Fq 'activeFocusOnTab:' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile actions cannot receive keyboard focus' +rg -Fq 'Keys.onReturnPressed:' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile actions cannot be triggered from the keyboard' + +for component in ThemeProfilePicker AccentPicker AccentEditor; do + rg -Fq "$component {" "$settings/AppearancePage.qml" \ + || fail "Appearance does not include $component" +done + +rg -Fq 'ThemeProfiles.activeProfile' "$theme" \ + || fail 'Theme roles do not react to the selected profile' +rg -Fq 'root.hyprColor(Theme.accent)' "$scheme" \ + || fail 'the focused border start does not follow Theme.accent' +rg -Fq 'root.hyprColor(Theme.accentSecondary)' "$scheme" \ + || fail 'the focused border end does not follow Theme.accentSecondary' + +for term in 'Theme profiles' 'Advanced accent' 'Pick colour from screen'; do + rg -Fq "$term" "$search" || fail "Settings search is missing $term" +done + +if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \ + "$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then + fail 'theme controls introduce continuously repainting or decorative animation' +fi + +state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)" +harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml" + +qs_for_test() { + XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \ + QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@" +} + +cleanup() { + qs_for_test kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_test --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' && break + sleep 0.1 +done +qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \ + || fail 'headless AccentEditor harness did not start' + +before="$(qs_for_test ipc call accent-controls-test status)" +jq -e '.id == "moon" and .shipped == true' <<<"$before" >/dev/null \ + || fail 'headless editor did not begin on Moon' +after="$(qs_for_test ipc call accent-controls-test adjust primary h 0)" +jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"' \ + <<<"$after" >/dev/null \ + || fail 'an HSV adjustment did not create a custom profile with the unchanged secondary colour' + +trap - EXIT +cleanup +printf 'accent controls contract: PASS\n' diff --git a/tests/quickshell/adwaita-accent-contract b/tests/quickshell/adwaita-accent-contract index 1192f90..4cc6c34 100755 --- a/tests/quickshell/adwaita-accent-contract +++ b/tests/quickshell/adwaita-accent-contract @@ -8,7 +8,8 @@ # inside Panama's own surfaces, which look correct either way. # # Three things have to line up, and none of them share a source: -# - config/Theme.qml carries the accent table with its `gnome` member +# - services/ThemeProfileModel.js carries the accent table with its `gnome` +# member (Theme.qml exposes it; the theme-profile system owns it) # - scripts/panama-theme-apps maps the same names in shell # - the portal routes Settings to a backend that can serve accent-color # @@ -17,7 +18,7 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -theme="$repo_dir/config/dot/quickshell/config/Theme.qml" +accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf" @@ -26,25 +27,24 @@ fail() { exit 1 } -for path in "$theme" "$script" "$portals"; do +for path in "$accents" "$script" "$portals"; do [[ -r "$path" ]] || fail "missing $path" done # ── The accent table ───────────────────────────────────────────────────────── -mapping="$(python3 - "$theme" <<'PYTHON' +mapping="$(python3 - "$accents" <<'PYTHON' import re, sys source = open(sys.argv[1]).read() -table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S) +table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S) if not table: - raise SystemExit("accents table not found") -for line in table.group(1).splitlines(): - name = re.search(r'"([a-z]+)":', line) - if not name: - continue - member = re.search(r'gnome: "([a-z]+)"', line) - print(name.group(1) + "\t" + (member.group(1) if member else "")) + raise SystemExit("curated accent table not found") +# Each accent is a multi-line record now, so the name opens a block and the +# member may be several lines below it. +for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S): + member = re.search(r'gnome: "([a-z]+)"', entry.group(2)) + print(entry.group(1) + "\t" + (member.group(1) if member else "")) PYTHON -)" || fail 'could not read the accent table from Theme.qml' +)" || fail 'could not read the curated accent table from ThemeProfileModel.js' [[ -n "$mapping" ]] || fail 'the accent table is empty' diff --git a/tests/quickshell/control-center-contract b/tests/quickshell/control-center-contract index 8f6829e..f11d210 100755 --- a/tests/quickshell/control-center-contract +++ b/tests/quickshell/control-center-contract @@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \ || fail 'Home tile does not give its dimmer an accessory-specific accessible name' +# Controls added after the original contract remain service-backed rather than +# becoming optimistic local toggles. Keep these checks in the static section so +# they can run without mapping the Control Center on a daily-driver desktop. +rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile control does not follow daemon availability' +rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \ + || fail 'opening Control Center does not refresh the external power profile' +rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices have no detail section' +rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices are not mounted' + +rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light control does not reflect service state' +rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light primary action is not wired' +rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light schedule action is not wired' + +rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control does not expose its current state' +rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control bypasses the preference commit path' + +if [[ "${1:-}" == "--static-only" ]]; then + trap - EXIT + cleanup + printf 'Control Center contract (static): PASS\n' + exit 0 +fi + cp -a "$source_config_path" "$config_path" : >"$helper_log" cat >"$config_path/scripts/panama-home-assistant" <<'EOF' diff --git a/tests/quickshell/theme-profiles-contract.sh b/tests/quickshell/theme-profiles-contract.sh new file mode 100755 index 0000000..9520d16 --- /dev/null +++ b/tests/quickshell/theme-profiles-contract.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" + +node - "$model" <<'JS' +const assert = require('node:assert/strict') +const model = require(process.argv[2]) + +const shipped = model.shippedProfiles() +assert.deepEqual(shipped, [ + { + id: 'moon', + name: 'Moon', + scheme: 'dark', + accent: '#82aaff', + secondary: '#b172b0', + shipped: true + }, + { + id: 'moon-rose', + name: 'Moon Rose', + scheme: 'dark', + accent: '#ff757f', + secondary: '#c099ff', + shipped: true + }, + { + id: 'day', + name: 'Day', + scheme: 'light', + accent: '#2e7de9', + secondary: '#9854f1', + shipped: true + } +]) + +const first = model.createCustomProfile([], { + name: ' Ocean ', + scheme: 'dark', + accent: '#86E1FC', + secondary: '#82AAFF' +}) +assert.deepEqual(first.profile, { + id: 'custom-ocean', + name: 'Ocean', + scheme: 'dark', + accent: '#86e1fc', + secondary: '#82aaff', + shipped: false +}) + +const second = model.createCustomProfile(first.profiles, { + name: 'ocean', + scheme: 'light', + accent: '#007197', + secondary: '#2e7de9' +}) +assert.equal(second.profile.name, 'ocean 2') +assert.equal(second.profile.id, 'custom-ocean-2') + +const bounded = model.createCustomProfile(second.profiles, { + name: 'A theme name that is deliberately much longer than forty characters', + scheme: 'dark', + accent: '#c3e88d', + secondary: '#86e1fc' +}) +assert.equal(bounded.profile.name.length, 40) + +const originalMoon = shipped[0] +const edited = model.editProfile([], originalMoon, { + accent: '#ffc777', + secondary: '#ff966c' +}) +assert.equal(edited.profile.shipped, false) +assert.equal(edited.profile.name, 'Moon custom') +assert.equal(edited.profiles.length, 1) +assert.deepEqual(originalMoon, shipped[0]) +assert.equal(shipped[0].accent, '#82aaff') + +const refusedDelete = model.deleteProfile(edited.profiles, 'moon') +assert.equal(refusedDelete.removed, false) +assert.deepEqual(refusedDelete.profiles, edited.profiles) + +assert.deepEqual(model.profileCatalog([ + edited.profile, + { id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false }, + { id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false } +]), [...shipped, edited.profile]) + +assert.equal(model.hsvToHex(0, 100, 100), '#ff0000') +assert.equal(model.hsvToHex(120, 100, 100), '#00ff00') +assert.equal(model.hsvToHex(240, 100, 100), '#0000ff') +assert.equal(model.hsvToHex(360, 100, 100), '#ff0000') +assert.equal(model.hsvToHex(0, 0, 50), '#808080') +assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 }) +assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 }) +assert.equal(model.hexToHsv('not-a-colour'), null) +assert.equal(model.curatedNameForProfile(shipped[0]), 'blue') +assert.equal(model.curatedNameForProfile(shipped[1]), 'rose') +assert.equal(model.matchingShippedProfile( + 'dark', '#ff757f', '#c099ff' +).id, 'moon-rose') +assert.equal(model.matchingShippedProfile( + 'light', '#2e7de9', '#9854f1' +).id, 'day') +assert.equal(model.curatedNameForProfile({ + id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark', + accent: '#123456', secondary: '#654321', shipped: false +}), '') + +console.log('theme profiles contract: PASS') +JS + +harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml" +state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)" + +qs_for_test() { + XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \ + QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@" +} + +cleanup() { + qs_for_test kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_test --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break + sleep 0.1 +done +qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \ + || { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; } + +status="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null + +qs_for_test ipc call theme-profiles-test scheme light >/dev/null +jq -e '.active.id == "day" and .active.scheme == "light"' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null + +qs_for_test ipc call theme-profiles-test select day >/dev/null +jq -e '.active.id == "day" and .active.scheme == "light"' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null + +qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null +edited="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \ + <<<"$edited" >/dev/null +custom_id="$(jq -r '.active.id' <<<"$edited")" + +qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null +saved="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null + +[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]] +forest_id="$(jq -r '.active.id' <<<"$saved")" +[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]] +jq -e '.active.id == "day" and .active.shipped == true' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null +[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]] + +trap - EXIT +cleanup +printf 'theme profiles service contract: PASS\n'