Complete the Panama theme system

This commit is contained in:
Gabriel Brown
2026-08-20 22:03:08 -04:00
parent 69ecec7ecf
commit ed428e87c4
17 changed files with 1370 additions and 41 deletions
@@ -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();
}
}
}
@@ -975,6 +975,19 @@ Singleton {
{ value: "slate", label: "Slate" } { 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 ───────────────────────────────────────────── // ── 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
+12 -21
View File
@@ -14,6 +14,7 @@ import Quickshell
// QtQuick is required even though nothing visual is declared here: `color` is a // QtQuick is required even though nothing visual is declared here: `color` is a
// QtQuick value type, and Qt.rgba() lives in its JS namespace. // QtQuick value type, and Qt.rgba() lives in its JS namespace.
import QtQuick import QtQuick
import qs.services
Singleton { Singleton {
id: root id: root
@@ -60,30 +61,20 @@ Singleton {
// Blue is the shipped Prism -- blue leading, orchid following -- and stays // Blue is the shipped Prism -- blue leading, orchid following -- and stays
// the default. // the default.
// //
// `gnome` is the nearest member of GNOME's own accent-color enum, which is // The table itself lives in services/ThemeProfileModel.js so a curated
// a fixed list of nine we do not get to extend. It is what libadwaita // 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 // applications -- Files, Papers, Loupe -- are told to use, so choosing an
// accent here recolors them too instead of leaving them in GNOME blue. // 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 readonly property var accents: ThemeProfiles.curatedAccents
// it is the red role in this palette. readonly property var activeProfile: ThemeProfiles.activeProfile
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" }
})
// Falls back to blue for an unknown name, so a settings file written by a // ThemeProfiles validates every persisted record before it can become
// newer Panama -- or edited by hand -- degrades to the shipped identity // active, so these bindings are both reactive and safe to expose as the
// rather than to an undefined color. // shell-wide colour roles.
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"] readonly property color accent: root.activeProfile.accent
readonly property color accentSecondary: root.activeProfile.secondary
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,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.";
}
}
}
@@ -18,7 +18,7 @@ Flow {
spacing: 10 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 // 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 // hand-kept in sync is how they drift. This is the same pattern
@@ -60,6 +60,8 @@ Flow {
} }
Rectangle { Rectangle {
id: swatch
width: 46 width: 46
height: 46 height: 46
radius: 23 radius: 23
@@ -67,8 +69,25 @@ Flow {
color: "transparent" color: "transparent"
// The ring sits outside the gradient rather than over it, so a // The ring sits outside the gradient rather than over it, so a
// selected swatch still shows its true colors. // selected swatch still shows its true colors.
border.width: entry.selected ? 2 : 1 border.width: swatch.activeFocus || entry.selected ? 2 : 1
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14) 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 { Rectangle {
anchors.fill: parent anchors.fill: parent
@@ -81,6 +100,9 @@ Flow {
GradientStop { position: 1.0; color: entry.end } 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 // Always shown, not a tooltip. Telling swatches apart by color is
@@ -129,18 +129,26 @@ SettingsPage {
SettingsCard { SettingsCard {
visible: root.tab === "theme" 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 !== "" subtitle: ColorScheme.lastError !== ""
? 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, // Drawn as the gradient each accent produces rather than a flat dot,
// because the gradient is what is being chosen. // because the gradient is what is being chosen.
AccentPicker { AccentPicker {
width: parent.width width: parent.width
} }
AccentEditor {
width: parent.width
}
} }
SettingsCard { SettingsCard {
@@ -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()
}
}
}
}
@@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml AccentPicker 1.0 AccentPicker.qml
TextFieldRow 1.0 TextFieldRow.qml TextFieldRow 1.0 TextFieldRow.qml
AccentEditor 1.0 AccentEditor.qml
ThemeProfilePicker 1.0 ThemeProfilePicker.qml
@@ -93,6 +93,14 @@ Singleton {
function onRevisionChanged(): void { coalesce.restart(); } 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 { Timer {
id: coalesce id: coalesce
interval: 250 interval: 250
@@ -136,7 +136,10 @@ Singleton {
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }, { 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: "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: "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 { function pageFor(group: string): string {
@@ -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
};
}
@@ -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(); }
}
}
@@ -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);
}
}
}
+133
View File
@@ -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'
+13 -13
View File
@@ -8,7 +8,8 @@
# inside Panama's own surfaces, which look correct either way. # inside Panama's own surfaces, which look correct either way.
# #
# Three things have to line up, and none of them share a source: # 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 # - scripts/panama-theme-apps maps the same names in shell
# - the portal routes Settings to a backend that can serve accent-color # - the portal routes Settings to a backend that can serve accent-color
# #
@@ -17,7 +18,7 @@
set -uo pipefail set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 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" script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf" portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf"
@@ -26,25 +27,24 @@ fail() {
exit 1 exit 1
} }
for path in "$theme" "$script" "$portals"; do for path in "$accents" "$script" "$portals"; do
[[ -r "$path" ]] || fail "missing $path" [[ -r "$path" ]] || fail "missing $path"
done done
# ── The accent table ───────────────────────────────────────────────────────── # ── The accent table ─────────────────────────────────────────────────────────
mapping="$(python3 - "$theme" <<'PYTHON' mapping="$(python3 - "$accents" <<'PYTHON'
import re, sys import re, sys
source = open(sys.argv[1]).read() 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: if not table:
raise SystemExit("accents table not found") raise SystemExit("curated accent table not found")
for line in table.group(1).splitlines(): # Each accent is a multi-line record now, so the name opens a block and the
name = re.search(r'"([a-z]+)":', line) # member may be several lines below it.
if not name: for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S):
continue member = re.search(r'gnome: "([a-z]+)"', entry.group(2))
member = re.search(r'gnome: "([a-z]+)"', line) print(entry.group(1) + "\t" + (member.group(1) if member else ""))
print(name.group(1) + "\t" + (member.group(1) if member else ""))
PYTHON 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' [[ -n "$mapping" ]] || fail 'the accent table is empty'
+31
View File
@@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \ rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name' || 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" cp -a "$source_config_path" "$config_path"
: >"$helper_log" : >"$helper_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF' cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
+169
View File
@@ -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'