Complete the Panama theme system
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
AccentPicker 1.0 AccentPicker.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(); }
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+133
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Executable
+169
@@ -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'
|
||||
Reference in New Issue
Block a user