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,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