Merge the Panama roadmap completion work

Six commits, rebased onto main and integrated with where main had moved in the
sixty-two commits since the branch was cut.

What lands: a theme-profile system, so a curated accent and a saved custom theme
become the same kind of record; a dock context menu; contextual Settings routes
from the dock, notifications and the OSD; overview-thumbnail capture reliability;
and wallpaper and lock-screen hardening -- which needed the shipped wallpaper
that the fresh-install work made exist, and was the dependency that kept this
branch parked.

Where main had moved further along the same path, main won: the focused border
driven by the chosen accent, the gradient built through the shared serializer,
the multi-edge dock. The branch's work sits on top of those. The one thing that
had to be carried across by hand was the `gnome` member on each accent, which is
what recolors libadwaita applications; it moved into the curated table rather
than being lost with the table it replaced.

One behaviour change worth knowing: the OSD now accepts pointer input, where it
previously accepted none. A secondary click on a visible OSD opens its settings,
and a Wayland region cannot admit one button and refuse another. The region stays
bounded to the OSD's own card.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
This commit is contained in:
Gabriel Brown
2026-08-20 22:36:41 -04:00
43 changed files with 2161 additions and 101 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" }
]
},
{
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
+12 -21
View File
@@ -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"
@@ -6,6 +6,10 @@ import qs.config
import qs.services
ShellRoot {
// The isolated screen model begins with both fixture outputs so changing
// it below exercises the same reactive topology path as a real hotplug.
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
IpcHandler {
target: "displays-test"
@@ -42,6 +46,7 @@ ShellRoot {
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
@@ -141,5 +146,8 @@ ShellRoot {
if (monitor) Displays.forget(monitor.name);
}
function refresh(): void { Displays.refresh(); }
function setScreenModel(names: string): void {
Displays.screenOverride = JSON.parse(names);
}
}
}
@@ -210,6 +210,12 @@ PanelWindow {
DockBody {
id: body
onContextMenuRequested: (anchorItem, entry) => {
dockContextMenu.anchorItem = anchorItem;
dockContextMenu.entry = entry;
dockContextMenu.visible = true;
}
vertical: root.vertical
leftSide: root.position === "left"
@@ -260,4 +266,8 @@ PanelWindow {
}
}
}
DockContextMenu {
id: dockContextMenu
}
}
@@ -116,6 +116,7 @@ Rectangle {
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
signal contextMenuRequested(Item anchorItem, var entry)
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
@@ -170,6 +171,7 @@ Rectangle {
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
}
}
}
@@ -0,0 +1,84 @@
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
// configuration route is deliberately last so it never displaces app actions.
import Quickshell
import QtQuick
import qs.config
import qs.modules.bar
import qs.services
import qs.widgets
PopupWindow {
id: root
property Item anchorItem: null
property var entry: null
anchor.item: root.anchorItem
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Top | Edges.Right
anchor.margins.bottom: 8
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
color: "transparent"
visible: false
grabFocus: true
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Column {
id: menu
anchors.fill: parent
anchors.margins: Theme.popoverPadding
spacing: 2
Repeater {
id: applicationActions
model: root.entry ? root.entry.actions : []
delegate: TrayMenuRow {
required property var modelData
width: parent.width
label: modelData.name
onActivated: {
modelData.execute();
root.visible = false;
}
}
}
Rectangle {
width: parent.width
height: 1
anchors.margins: 3
visible: applicationActions.count > 0
border.width: 0
color: Theme.alpha(Theme.fg, 0.1)
}
TrayMenuRow {
width: parent.width
label: "Dock settings"
onActivated: {
ShellState.openSettings("desktop");
root.visible = false;
}
}
}
}
}
@@ -24,6 +24,7 @@ Item {
// Emitted so DockBody can drive the single shared tooltip.
signal entered
signal exited
signal contextMenuRequested
// The icon may grow past the cell on hover; the cell itself stays a fixed
// size so the row doesn't reflow.
@@ -115,7 +116,7 @@ Item {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onEntered: root.entered()
onExited: root.exited()
@@ -126,6 +127,10 @@ Item {
root.launch();
return;
}
if (mev.button === Qt.RightButton) {
root.contextMenuRequested();
return;
}
if (root.running)
root.focusNext();
else
@@ -14,6 +14,7 @@ import Quickshell.Services.Notifications
import qs.config
import qs.services
import qs.modules.quicksettings
import qs.modules.bar
import qs.widgets
Rectangle {
@@ -127,11 +128,44 @@ Rectangle {
onClicked: root.dismissed()
}
IconButton {
id: settingsMenuButton
anchors.right: closeButton.left
anchors.rightMargin: 2
anchors.top: parent.top
anchors.topMargin: 6
size: 24
iconSize: 14
tint: Theme.fgDim
icon: "view-more-symbolic"
iconFallback: "open-menu-symbolic"
onClicked: settingsMenu.visible = !settingsMenu.visible
}
Popover {
id: settingsMenu
anchorItem: settingsMenuButton
// A Column already measures itself from its children, and in Qt 6 both
// implicit sizes are read-only on a positioner -- assigning them makes
// the whole shell fail to load rather than just this menu.
Column {
TrayMenuRow {
id: notificationSettings
label: "Notification settings"
onActivated: {
ShellState.openSettings("notifications");
settingsMenu.visible = false;
}
}
}
}
Column {
id: layout
anchors.left: appIcon.visible ? appIcon.right : parent.left
anchors.leftMargin: appIcon.visible ? 10 : 14
anchors.right: image.visible ? image.left : closeButton.left
anchors.right: image.visible ? image.left : settingsMenuButton.left
anchors.rightMargin: 8
anchors.top: parent.top
anchors.topMargin: 12
+19 -1
View File
@@ -34,7 +34,14 @@ PanelWindow {
implicitWidth: root.desiredWidth
implicitHeight: 64
color: "transparent"
mask: Region {}
mask: Region {
item: inputMask
}
Item {
id: inputMask
anchors.fill: parent
}
WlrLayershell.namespace: "qs-popover-osd"
WlrLayershell.layer: WlrLayer.Overlay
@@ -145,6 +152,17 @@ PanelWindow {
font.weight: Font.DemiBold
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: event => {
if (event.button !== Qt.RightButton)
return;
ShellState.openSettings("accessibility");
OsdState.hide();
}
}
}
TextMetrics {
@@ -176,33 +176,33 @@ Item {
height: aspect > 0 ? width / aspect : parent.height
opacity: hasContent ? 1 : 0
// The capture context isn't ready the instant the layer surface is
// told to become visible — it needs the surface to actually map,
// which takes a frame or two. Calling captureFrame() before then
// logs "no recording context is ready" and yields nothing, so
// retry a few times and then give up quietly (the caption and app
// icon are still shown, so a missing thumbnail is cosmetic).
property int captureAttempts: 0
// A ScreencopyView cannot tell us that its recording context is
// ready before a capture. The enclosing overview gets that
// context only after its first rendered frame, so defer the first
// one-shot capture to that event. Retrying before then only emits
// "no recording context is ready" warnings; if a capture later
// cannot produce content, the app icon remains the fallback.
property bool recordingReady: false
function tryCapture(): void {
captureAttempts = 0;
captureRetry.restart();
if (shot.hasContent)
return;
if (!shot.recordingReady) {
frameReady.restart();
return;
}
shot.captureFrame();
}
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
// resolve to the enclosing ScreencopyView.
Timer {
id: captureRetry
interval: 80
repeat: true
FrameAnimation {
id: frameReady
running: false
onTriggered: {
if (shot.hasContent || shot.captureAttempts >= 6) {
stop();
running = false;
if (shot.hasContent)
return;
}
shot.captureAttempts++;
shot.captureFrame();
shot.recordingReady = true;
shot.tryCapture();
}
}
@@ -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 {
@@ -22,7 +22,7 @@ SettingsPage {
property string selectedOutput: ""
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null)
readonly property string currentMode: root.monitor
? root.monitor.mode
: ""
@@ -38,7 +38,8 @@ SettingsPage {
function syncSelectedOutput(): void {
if (!Displays.monitorNamed(root.selectedOutput))
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
? Displays.primaryFirstMonitors[0].name : "";
}
// Probing I2C for DDC-capable monitors takes on the order of a second, so
@@ -137,7 +138,7 @@ SettingsPage {
ChoiceGrid {
width: parent.width
label: "Display"
options: Displays.monitors.map(monitor => ({
options: Displays.primaryFirstMonitors.map(monitor => ({
value: monitor.name,
label: monitor.description || monitor.name
}))
@@ -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()
}
}
}
}
@@ -18,6 +18,13 @@ Column {
width: parent ? parent.width : 620
spacing: 4
onOutputsChanged: root.syncSelectedOutput()
function syncSelectedOutput(): void {
if (!root.outputs.includes(root.selectedOutput))
root.selectedOutput = root.outputs.length > 0 ? root.outputs[0] : "";
}
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
@@ -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
+26 -8
View File
@@ -17,8 +17,10 @@ status_file="$state_dir/hyprlock-status.json"
fallback="$config_home/hypr/hyprlock.conf"
temporary="$generated.tmp.$$"
status_temporary="$status_file.tmp.$$"
shipped_wallpaper="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
settings_valid=false
wallpaper_warning=""
cleanup() {
rm -f "$temporary" "$status_temporary" 2>/dev/null || true
@@ -76,7 +78,23 @@ read_object() {
}
valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* && -f "$1" && -r "$1" ]]
}
resolve_wallpaper_path() {
local candidate="$1"
if valid_path "$candidate"; then
resolved_wallpaper="$candidate"
return
fi
if [[ -n "$candidate" ]]; then
wallpaper_warning="One or more lock-screen wallpapers were unavailable; a safe fallback is in use."
fi
if valid_path "$shipped_wallpaper"; then
resolved_wallpaper="$shipped_wallpaper"
else
resolved_wallpaper="screenshot"
fi
}
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
@@ -134,10 +152,9 @@ load_preferences() {
wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single
wallpaper_path="$(read_string wallpaperPath '')"
if ! valid_path "$wallpaper_path"; then
wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
fi
wallpaper_warning=""
resolve_wallpaper_path "$(read_string wallpaperPath '')"
wallpaper_path="$resolved_wallpaper"
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
case "$blur_level" in
@@ -218,8 +235,9 @@ emit_backgrounds() {
candidate="$(jq -r --arg output "$output" \
'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
<<<"$wallpaper_assignments" 2>/dev/null || true)"
if valid_path "$candidate"; then
assigned="$candidate"
if [[ -n "$candidate" ]]; then
resolve_wallpaper_path "$candidate"
assigned="$resolved_wallpaper"
fi
fi
emit_background "$output" "$assigned"
@@ -344,7 +362,7 @@ generate() {
write_status false "$fallback" true "The lock-screen configuration could not be generated."
return 1
fi
write_status true "$generated" false ""
write_status true "$generated" false "$wallpaper_warning"
}
status() {
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Finds wallpaper candidates from fixed roots passed as separate arguments.
# Keeping paths as argv values avoids shell interpolation for names containing
# quotes, spaces, or other shell syntax.
set -euo pipefail
find "$@" -maxdepth 2 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \
-printf '%T@ %p\n' 2>/dev/null \
| sort -rn \
| cut -d' ' -f2- \
| awk 'NR <= 60'
@@ -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
@@ -29,8 +29,24 @@ Singleton {
// [{ name, description, width, height, refreshRate, scale, transform,
// modes: [{ label, mode, width, height, refresh }] }]
property var monitors: []
// Quickshell.screens is the topology authority. The override is only the
// isolated harness model; normal sessions always observe Quickshell.
property var screenOverride: null
property string lastError: ""
readonly property var screenModel: Array.isArray(root.screenOverride)
? root.screenOverride : Quickshell.screens
readonly property string screenSignature: root.screenModel
.map(screen => typeof screen === "string" ? screen : screen.name)
.filter(name => !!name)
.sort()
.join("|")
readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => {
if (left.primary !== right.primary)
return left.primary ? -1 : 1;
return left.name.localeCompare(right.name);
})
// Set while a change is applied but not yet confirmed.
property var pendingPreviousLayout: null
property var pendingRequestedLayout: null
@@ -113,6 +129,17 @@ Singleton {
Component.onCompleted: root.refresh()
onScreenSignatureChanged: root.reconcileTopology()
// A hotplug can invalidate the unconfirmed layout while the confirmation
// is visible. Read the current compositor layout first; the normal queued
// rollback then filters out any output that has disappeared.
function reconcileTopology(): void {
root.refresh();
if (root.awaitingConfirmation)
root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting.");
}
function refresh(): bool {
if (!query.running) {
query.generation = root.operationGeneration;
@@ -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(); }
}
}
+18 -7
View File
@@ -45,9 +45,10 @@ Singleton {
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
property var outputNames: function() {
if (Array.isArray(root.outputOverride))
return root.outputOverride.slice();
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
const outputs = Array.isArray(root.outputOverride)
? root.outputOverride.slice()
: Quickshell.screens.map(screen => screen.name).filter(name => !!name);
return root.primaryFirstOutputs(outputs);
}
readonly property var searchRoots: [
@@ -59,10 +60,8 @@ Singleton {
Process {
id: scan
command: ["bash", "-lc",
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"]
.concat(root.searchRoots)
stdout: StdioCollector {
onStreamFinished: {
root.available = this.text.split("\n")
@@ -164,6 +163,18 @@ Singleton {
activeQuery.running = true;
}
// The saved primary role anchors Panama's per-monitor choices. Keep the
// compositor's remaining order stable so a reconnect does not reshuffle
// the rest of the picker unnecessarily.
function primaryFirstOutputs(outputs: var): var {
const stored = DesktopPreferences.get("displays");
const layouts = stored && typeof stored === "object" ? stored : {};
const primary = outputs.find(output => layouts[output]?.primary === true);
return primary === undefined
? outputs
: [primary].concat(outputs.filter(output => output !== primary));
}
function candidates(extra: var): var {
const result = [];
const discovered = Array.isArray(root.candidateOverride)
@@ -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);
}
}
}
@@ -7,7 +7,7 @@ import qs.services
ShellRoot {
Component.onCompleted: {
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
Wallpaper.outputOverride = ["HDMI-A-1", "DP-2"];
Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
Wallpaper.startupRestoreEnabled = false;
Wallpaper.slideshowIntervalOverrideMs = 60000;
@@ -63,7 +63,9 @@ ShellRoot {
assignments: DesktopPreferences.get("wallpaperPerMonitor"),
slideshowPath: Wallpaper.slideshowPath,
shuffleBag: Wallpaper.shuffleBag,
slideshowTimerRunning: Wallpaper.slideshowTimerRunning
slideshowTimerRunning: Wallpaper.slideshowTimerRunning,
scanning: Wallpaper.scanning,
available: Wallpaper.available
});
}
}
@@ -46,6 +46,16 @@ ShellRoot {
return JSON.stringify(root.calls);
}
function selectOutput(output: string): void {
controls.selectedOutput = output;
}
function updateOutputs(outputs: string): void {
controls.outputs = outputs === "" ? [] : outputs.split("|");
}
function selectedOutput(): string { return controls.selectedOutput; }
function states(): string {
picker.mode = "single";
const single = {
@@ -0,0 +1,176 @@
# Panama Roadmap Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Close every audited correctness gap and finish the approved Phase 35 Panama roadmap.
**Architecture:** Preserve Settings, `DesktopPreferences`, and `Theme.qml` as the existing ownership spine. Add narrowly scoped event observers, adapters, and generated artifacts around those boundaries; contextual shell surfaces only route into Settings. Each task is independently committed and contract-tested before final integration.
**Tech Stack:** Quickshell/QML, JavaScript, Bash, Python 3, Hyprland Lua, systemd user units, `jq`.
**Spec:** `docs/superpowers/specs/2026-08-18-roadmap-completion-design.md`
## Global Constraints
- Preserve the current Prism/Tokyo Night design language and existing component library.
- Do not introduce continuously repainting effects or polling loops.
- Do not expose or modify `config/bash/env`.
- Write a failing contract before each production change and record the red/green commands.
- Keep interactive QML harnesses and Quickshell restarts for the final integration gate.
- Generated and user-owned files must be written atomically.
- No production configuration is activated until the reviewed branch is merged.
---
### Task 1: Close Phase 1 and Phase 2 safety gaps
**Files:**
- Modify: `config/dot/quickshell/services/ColorScheme.qml`
- Modify: `config/dot/quickshell/modules/settings/README.md`
- Modify: `config/dot/hypr/looks.lua`
- Modify: `config/dot/quickshell/services/Displays.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
- Modify: `config/dot/quickshell/scripts/panama-lock`
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
- Modify: `config/dot/quickshell/modules/settings/WallpaperControls.qml`
- Test: `tests/quickshell/settings-ownership-contract`
- Test: `tests/quickshell/display-transaction-contract`
- Test: `tests/quickshell/lock-screen-helper-contract`
- Test: `tests/quickshell/wallpaper-service-contract`
- Test: `tests/quickshell/wallpaper-settings-contract`
**Interfaces:**
- Produces: `Displays.primaryFirstMonitors`, event-driven topology refresh, and a lock helper that never emits an unreadable image.
- Consumes: current `Theme.accent`, `Theme.accentSecondary`, display transaction rollback, and wallpaper preference schema.
- [ ] Add failing ownership assertions that active borders derive from `Theme.accent` and `Theme.accentSecondary`, while inactive borders derive only from scheme roles; verify the current contract fails for the stale prohibition.
- [ ] Update the ownership implementation comments and documentation, then run `bash tests/quickshell/settings-ownership-contract` and verify PASS.
- [ ] Add a failing display fixture that changes the harness screen model without calling the display services public `refresh()` and expects topology reconciliation plus rollback of a pending transaction.
- [ ] Add an event-driven screen-model observer and primary-first derived monitor list; run `bash tests/quickshell/display-transaction-contract` and `bash tests/quickshell/display-layout-contract`.
- [ ] Add failing lock fixtures for missing global and per-monitor wallpapers, requiring readable shipped-image fallback and screenshot fallback when no image exists.
- [ ] Tighten `panama-lock` path validation and bounded warnings; run `bash tests/quickshell/lock-screen-helper-contract`.
- [ ] Add failing wallpaper contracts for primary-first output order, selection preservation, and a HOME path containing a single quote.
- [ ] Replace inline `bash -lc` path interpolation with an argument-safe helper and update controls to preserve selection; run both wallpaper contracts.
- [ ] Run the Task 1 contract set and commit with message `Close desktop safety gaps`.
### Task 2: Complete contextual configuration and reliable overview capture
**Files:**
- Modify: `config/dot/quickshell/modules/dock/Dock.qml`
- Modify: relevant dock context-menu component if already split
- Modify: `config/dot/quickshell/modules/notifications/NotificationCard.qml`
- Modify: `config/dot/quickshell/modules/osd/Osd.qml`
- Modify: `config/dot/quickshell/modules/overview/WindowThumbnail.qml`
- Modify: `config/dot/quickshell/services/ShellState.qml`
- Test: `tests/quickshell/settings-jump-contract`
- Create or modify: `tests/quickshell/overview-thumbnail-contract`
**Interfaces:**
- Produces: one route per configurable surface into the existing owning Settings page.
- Consumes: `ShellState.openSettings(page)` and the existing app-icon thumbnail fallback.
- [ ] Extend the settings-jump contract so it fails until dock, notifications, and OSD each expose a contextual Settings route without replacing their primary action.
- [ ] Add the three restrained contextual actions using existing menu/button styles and Settings routes.
- [ ] Add a failing static/isolated contract proving capture does not start before recording readiness, is bounded, and retains the icon fallback.
- [ ] Gate capture on recording readiness or the appropriate Quickshell frame-ready signal, remove warning-producing blind retries, and keep capture event-driven.
- [ ] Run both contracts and commit with message `Complete contextual desktop controls`.
### Task 3: Complete advanced accents and named themes
**Files:**
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Modify: `config/dot/quickshell/config/Theme.qml`
- Create: `config/dot/quickshell/services/ThemeProfiles.qml`
- Modify: `config/dot/quickshell/modules/settings/AccentPicker.qml`
- Create: `config/dot/quickshell/modules/settings/AccentEditor.qml`
- Create: `config/dot/quickshell/modules/settings/ThemeProfilePicker.qml`
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
- Modify: `config/dot/quickshell/services/ColorScheme.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Test: create `tests/quickshell/theme-profiles-contract`
- Test: create `tests/quickshell/accent-controls-contract`
- Test: modify `tests/quickshell/control-center-contract`
**Interfaces:**
- Produces: profile records `{id,name,scheme,accent,secondary,shipped}` and reactive `Theme` colour roles.
- Consumes: `DesktopPreferences`, `SystemSettings.commitPreference`, `hyprpicker`, and current Prism Settings components.
- [ ] Write failing pure-model fixtures for three shipped profiles, custom profile creation, bounded unique names, shipped-profile immutability, and HSV/hex conversion.
- [ ] Implement `ThemeProfiles` with Moon, Moon Rose, and Day plus saved custom profiles, then verify model tests.
- [ ] Write failing UI contracts for labelled HSV controls, curated swatches, keyboard focus, screen picking, profile save/switch/delete, and search routing.
- [ ] Build the profile picker and advanced editor from existing `SettingRow`, `ValueSlider`, `SettingsButton`, and focus tokens; do not add ambient animation.
- [ ] Update `Theme.qml` and `ColorScheme` so custom pairs reactively reach shell roles and Hyprland borders.
- [ ] Extend the Control Center contract to cover power profiles, night light, and colour scheme controls added after its original contract.
- [ ] Run theme, accent, ownership, settings-search, and Control Center contracts; commit with message `Complete the Panama theme system`.
### Task 4: Propagate the accent to external applications
**Files:**
- Modify: `config/dot/quickshell/scripts/panama-theme-apps`
- Modify: `config/dot/quickshell/services/ColorScheme.qml`
- Modify: tracked kitty/tmux/Vicinae/Wofi/Neovim include points as required
- Create: generated-file templates or helpers under the owning application directories
- Test: create `tests/quickshell/theme-apps-accent-contract`
- Test: modify `tests/quickshell/gtk-theme-contract` if its invocation contract changes
**Interfaces:**
- Produces: `panama-theme-apps dark|light [#RRGGBB #RRGGBB]` with per-adapter JSON status.
- Consumes: the selected profiles validated primary and secondary accents.
- [ ] Write a failing isolated fixture invoking the helper with dark/light and custom pairs, asserting atomic generated overrides for kitty, tmux, btop, Vicinae/Wofi, hyprlock, and Neovim plus backward-compatible two-state invocation.
- [ ] Implement validated optional accent arguments and independent adapters; never rewrite user-owned base files wholesale.
- [ ] Update each applications tracked config to include its generated accent override after the base Tokyo Night theme.
- [ ] Pass the selected pair from `ColorScheme` and expose bounded adapter failures through its existing error state.
- [ ] Run the isolated helper contract, GTK contract, Bash syntax, and a fixture-only idempotence check; commit with message `Propagate accents across desktop applications`.
### Task 5: Finish onboarding, recovery automation, and generated docs
**Files:**
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Create: `config/dot/quickshell/modules/settings/WelcomePage.qml`
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Modify: `config/dot/quickshell/services/SystemSettings.qml`
- Modify: display/theme/reset/restore call sites for pre-risk snapshots
- Modify: `setup/scripts/link-dotfiles`
- Create: user systemd snapshot service/timer files under the existing systemd config tree
- Create: `config/dot/quickshell/scripts/panama-generate-docs`
- Create: `docs/SETTINGS.md`
- Create: `docs/SHORTCUTS.md`
- Test: create `tests/quickshell/first-run-contract`
- Test: modify `tests/quickshell/settings-backup-live-contract`
- Test: create `tests/quickshell/generated-docs-contract`
- Test: modify `tests/quickshell/keybind-rebind-contract`
**Interfaces:**
- Produces: fresh-install marker lifecycle, `snapshot --cause <cause>` retention, and deterministic generated Markdown.
- Consumes: existing Settings navigation, backup/restore helper, preference schema, and shipped keybind metadata.
- [ ] Write failing first-run fixtures proving only fresh installs get a marker, completion removes it atomically, existing settings are not interrupted, and Welcome remains searchable.
- [ ] Implement the Welcome page and marker lifecycle using existing card/row components.
- [ ] Add failing backup fixtures for daily retention and pre-risk snapshots before display layout, named theme, reset-all, and restore operations.
- [ ] Extend the backup helper/service with labelled atomic snapshots, fourteen-day retention, and a systemd user timer; wire the four risky call sites.
- [ ] Strengthen keybind contracts to explicitly cover both rebind and reset collisions.
- [ ] Write a failing drift contract for generated settings and shortcut documentation.
- [ ] Implement the deterministic generator, generate both Markdown files, and verify a second run produces no diff.
- [ ] Run first-run, backup, keybind, generated-docs, schema, and search contracts; commit with message `Finish onboarding and desktop recovery`.
### Task 6: Integrate, review, activate, and verify live readiness
**Files:**
- Modify only files required by review findings.
- Update roadmap/status documentation to mark exact delivered scope.
**Interfaces:**
- Consumes: all preceding task commits.
- Produces: reviewed `main`, pushed origin, one activated Quickshell session, and an evidence ledger.
- [ ] Run `git diff --check`, executable Bash syntax, Lua syntax, generated-doc drift, every non-interactive Quickshell contract, and Hyprland configuration validation.
- [ ] Dispatch a whole-branch code review against this plan and fix every Critical/Important finding with one reviewed fix wave.
- [ ] Merge the reviewed branch into `main` without rewriting shared history and push `origin/main`.
- [ ] Run the repositorys user-level setup/link step needed for new systemd units and autostarts; do not print environment secrets.
- [ ] Resolve live health where locally actionable: enable RustDesk and Nextcloud user autostart, install/link calendar integration if its declared dependency is already available, and leave hardware-only DDC as a documented warning.
- [ ] Restart Quickshell once, perform the interactive visual/runtime gate, inspect logs for QML errors and screencopy warnings, and run `panama-doctor --json`.
- [ ] Record exact passed commands, remaining environment-only observations, and final commit IDs.
@@ -0,0 +1,127 @@
# Panama Roadmap Completion Design
## Goal
Finish the approved five-phase Panama desktop roadmap without diluting the
existing Prism/Tokyo Night identity or introducing parallel sources of truth.
The result must be safe enough for the daily desktop, understandable to a
normal GNOME/macOS user, and configurable without editing shell files.
## Fixed product decisions
- The existing Prism glass visual language, spacing, typography, and motion
tokens remain authoritative. New surfaces reuse existing Settings widgets.
- Curated named accents remain the fast path. Advanced colour controls add HSV
and screen picking without replacing the curated choices.
- Settings remain the sole configuration application. Contextual affordances
route into an owning Settings page instead of duplicating controls.
- User-selected accent is a theme role. `ColorScheme` applies it to external
systems but must never substitute a scheme-owned hard-coded focused colour.
- Existing installations must not be surprised by an automatic welcome flow.
Only fresh installs receive the first-run marker; the welcome page remains
manually discoverable later.
- No continuously repainting animation or background polling is introduced.
Hardware/topology changes are event-driven; periodic snapshots use a user
systemd timer.
## 1. Correctness and safety closure
The ownership contract and documentation will be updated to reflect the actual
boundary: `Theme` owns the active accent values, while `ColorScheme` owns
applying those values to Hyprland alongside its scheme-relative inactive role.
Displays will observe the Quickshell screen model. A topology change refreshes
the full compositor layout. If a confirmation transaction is active, it enters
the existing safe rollback path using only currently connected outputs. The
production observer, rather than an explicit test-only refresh, is exercised by
the transaction contract.
The lock generator will accept wallpaper paths only when they are absolute,
regular, and readable. Missing global or per-output images fall back to the
shipped wallpaper when readable, then to screenshot mode. Generation remains
atomic and returns a bounded user-facing warning.
Wallpaper discovery will move shell quoting into a narrow helper that accepts
paths as arguments. Per-monitor choices list the selected primary output first
and preserve a valid selection across topology changes.
## 2. Contextual configuration and overview reliability
Every configurable shell surface gets an unobtrusive route to its owning page:
- Bar widgets keep their current secondary-click behavior.
- Dock context menus include a final “Dock settings” action without replacing
application actions.
- Notification cards expose “Notification settings” in their overflow menu.
- A secondary click on a visible OSD opens Accessibility/OSD settings and then
dismisses the OSD.
Overview thumbnails remain event-driven. Capture begins only after the overview
surface has a valid recording context. Retry logic must not knowingly call
`captureFrame()` while the context is unavailable, must stop after a bounded
deadline, and must leave the existing app-icon fallback without warning spam.
## 3. Complete theme system
Appearance presents three layers in one hierarchy:
1. Shipped named themes: Moon, Moon Rose, and Day.
2. Saved user themes, capturing colour scheme and accent pair.
3. Advanced accent editing: hue, saturation, and value controls plus
`hyprpicker` screen sampling.
The persisted model contains a stable profile identifier, display name, colour
scheme, primary accent, and secondary accent. Curated swatches map onto shipped
profiles. A custom edit switches to a custom profile without mutating a shipped
definition. Saved names are trimmed, bounded, and unique.
`Theme.qml` remains the in-shell source of truth. The selected pair flows to the
bar, dock, panels, notifications, OSD, focused Hyprland border, and lock preview.
All controls have keyboard focus, visible focus treatment, and text labels; hue
is never the only signal.
## 4. External accent propagation
`panama-theme-apps` will accept scheme plus an optional validated accent pair.
Omitted accents preserve backward compatibility. It atomically writes small
generated overrides rather than rewriting user-owned base configuration.
The current accent pair propagates to kitty, tmux, btop, Vicinae/Wofi, the
managed lock screen, and Panama's Neovim theme hook. Running kitty/tmux/Vicinae
instances are refreshed only through supported live interfaces; applications
without safe reload retain the generated value for their next launch.
Each adapter reports `written`, `applied`, `skipped`, or `failed`. One adapter
failure does not prevent other targets from updating, but the shell receives a
bounded summary and exposes failures in health diagnostics.
## 5. First run, recovery, and generated documentation
The installer creates a first-run marker only for a new Panama settings store.
On the first shell session, Settings opens a restrained Welcome page covering:
launcher, tiling/window movement, Control Center, notifications/clipboard, and
health. Completion removes the marker atomically. The page can always be opened
from Settings search.
Existing shortcut collision refusal remains the conflict-detection mechanism
and gains contract coverage for both rebind and reset collisions.
Settings snapshots run daily through a user systemd timer and before these
risky operations: applying a complete display layout, applying a named theme,
resetting all preferences, and restoring a snapshot. Automatic snapshots are
labelled by cause, atomic, and retained for fourteen daily generations plus the
most recent snapshot for each risky cause.
A deterministic generator produces `docs/SETTINGS.md` and
`docs/SHORTCUTS.md` from the preference schema and shipped keybind metadata.
A contract fails when committed documentation differs from generated output.
## 6. Verification and rollout
Work is built in an isolated worktree. Unit/contract tests run per task. No
interactive QML harness is opened on the daily desktop until the final gate.
The final gate includes every Quickshell contract, shell/Lua syntax, Hyprland
configuration validation, generated-document drift, a clean runtime log, and a
manual visual pass after merge. The branch is merged to `main`, pushed, and only
then activated by restarting Quickshell once.
+140
View File
@@ -0,0 +1,140 @@
#!/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
# The curated swatches come from the accentName schema rather than from
# Object.keys(Theme.accents): the schema's option order is the palette's order,
# and it is the same source every other enum row reads. What matters here is
# unchanged -- the curated accents are still one click away, ahead of the
# editor.
rg -Fq 'PreferenceSchema.spec("accentName")' "$settings/AccentPicker.qml" \
|| fail 'curated swatches are no longer sourced from the accent schema'
rg -Fq 'model: root.options' "$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.
#
# 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'
+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" \
|| 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'
@@ -223,16 +223,18 @@ failed_state="$(wait_for '.busy == false and .awaiting == false')"
jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \
|| fail "failed apply did not retain a useful recovery message: $failed_state"
# If an output disconnects while a change is pending, rollback sends one
# transaction containing every output that is still connected.
# If an output disconnects while a change is pending, the screen-model observer
# refreshes topology and rolls back with only the still-connected output. This
# intentionally does not call Displays.refresh() through the public harness API.
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \
|| fail 'disconnect fixture could not start'
wait_for '.canConfirm == true' >/dev/null
jq '.[0:1]' "$monitor_state" >"$fixture/connected.json"
mv "$fixture/connected.json" "$monitor_state"
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
wait_for '.layout | length == 1' >/dev/null
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
run ipc --pid "$harness_pid" call displays-test setScreenModel '["DP-2"]' >/dev/null
wait_for '(.layout | length == 1) and .awaiting == false and .busy == false' >/dev/null
[[ "$(transaction_status | jq -c .primaryFirst)" == '["DP-2"]' ]] \
|| fail "primary-first monitor list did not reconcile after hot-unplug: $(transaction_status)"
wait_for '.busy == false and .awaiting == false' >/dev/null
disconnect_payload="$(tail -1 "$eval_log")"
[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \
+5 -1
View File
@@ -54,7 +54,11 @@ rg -Fq 'options: Displays.scalesForMode(' "$page" \
|| fail 'scale choices are not filtered for the active resolution'
rg -Fq 'property string selectedOutput:' "$page" \
|| fail 'connected outputs cannot be selected'
rg -Fq 'options: Displays.monitors.map(' "$page" \
# primaryFirstMonitors is monitors sorted with the primary first, so the
# selector is still populated from what is connected -- which is what this
# protects. Naming the sorted list rather than the raw one is the point: the
# picker should open on the display somebody is most likely to mean.
rg -Fq 'options: Displays.primaryFirstMonitors.map(' "$page" \
|| fail 'the output selector is not populated from connected displays'
rg -Fq 'id: revertVerifyTimer' "$service" \
|| fail 'automatic restoration has no bounded readback verification'
+33 -8
View File
@@ -14,6 +14,9 @@ settings="$config_home/panama/settings.json"
generated="$state_home/panama/hyprlock.conf"
hyprlock_log="$fixture/hyprlock.log"
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
shipped_wallpaper="$fixture_home/Pictures/Wallpapers/faroe_islands.jpg"
global_wallpaper="$fixture_home/Pictures/Wallpapers/global.jpg"
portrait_wallpaper="$fixture_home/Pictures/Wallpapers/portrait.jpg"
fail() {
printf 'lock screen helper contract: %s\n' "$1" >&2
@@ -28,6 +31,9 @@ trap cleanup EXIT
mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \
"$config_home/hypr" "$state_home" "$test_bin"
printf 'shipped wallpaper\n' >"$shipped_wallpaper"
printf 'global wallpaper\n' >"$global_wallpaper"
printf 'portrait wallpaper\n' >"$portrait_wallpaper"
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
"$config_home/hypr/hyprlock.conf.template"
HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" \
@@ -131,19 +137,38 @@ write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}'
run_helper generate
rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue'
write_settings '{
"lockBackgroundMode":"wallpaper",
"wallpaperMode":"per-monitor",
"wallpaperPath":"/images/global.jpg",
"wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"}
}'
write_settings "$(jq -n \
--arg global "$global_wallpaper" \
--arg portrait "$portrait_wallpaper" \
'{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$global,wallpaperPerMonitor:{"DP-2":$portrait}}')"
run_helper generate
[[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output'
awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
awk -v path="$portrait_wallpaper" '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \
|| fail 'per-monitor wallpaper was not used for DP-2'
awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
awk -v path="$global_wallpaper" '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \
|| fail 'missing monitor assignment did not fall back to the global wallpaper'
# A selected but unreadable image never reaches hyprlock. One bounded warning
# covers both missing global and per-monitor choices while the shipped image is
# still readable.
write_settings "$(jq -n \
--arg missing "$fixture/missing.jpg" \
'{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$missing,wallpaperPerMonitor:{"DP-2":$missing}}')"
run_helper generate
[[ "$(rg -c "path = $shipped_wallpaper" "$generated")" -eq 2 ]] \
|| fail 'missing selected wallpapers did not fall back to the readable shipped image'
jq -e '.generated == true and .fallback == false
and .error == "One or more lock-screen wallpapers were unavailable; a safe fallback is in use."' \
<<<"$(run_helper status)" >/dev/null \
|| fail 'missing wallpaper fallback did not report one bounded warning'
# If no image is readable, screenshot is the only background source that
# hyprlock can still render safely.
rm -f "$shipped_wallpaper"
run_helper generate
[[ "$(rg -c 'path = screenshot' "$generated")" -eq 2 ]] \
|| fail 'missing wallpapers without the shipped image did not fall back to screenshots'
write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}'
run_helper generate
rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored'
+14 -1
View File
@@ -33,7 +33,20 @@ rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \
|| fail 'OSD does not use the existing Prism blur namespace'
rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \
|| fail 'OSD may steal keyboard focus'
rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input'
# The OSD used to take no pointer input at all. It now takes some, because a
# secondary click on a visible OSD opens its settings and dismisses it -- and a
# Wayland input region cannot be told to admit one button and not another, so
# the feature is not available without a region.
#
# What still has to hold is that the region is BOUNDED to the OSD's own surface
# rather than the screen. The OSD floats over whatever is underneath for a
# couple of seconds; a region larger than the card would swallow clicks meant
# for a window nobody could see was being covered.
rg -Fq 'mask: Region {' "$osd" || fail 'OSD declares no input region at all'
rg -Fq 'item: inputMask' "$osd" \
|| fail 'the OSD input region is not bound to an item, so it may cover more than the OSD itself'
rg -Fq 'anchors.fill: parent' "$osd" \
|| fail 'the OSD input mask does not track the surface it belongs to'
rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge'
rg -Fq 'font.features: Theme.tabularFigures' "$osd" \
|| fail 'changing percentages do not use tabular figures'
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# A ScreencopyView only has content *after* capture. It is not a readiness
# signal, so an overview thumbnail must first wait for its enclosing layer
# surface to render a frame. This contract keeps that warning-prone boundary
# explicit without opening a QML test window.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
thumbnail="$repo_dir/config/dot/quickshell/modules/overview/WindowThumbnail.qml"
fail() {
printf 'overview thumbnail contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$thumbnail" ]] || fail 'WindowThumbnail.qml is missing'
grep -qF 'FrameAnimation {' "$thumbnail" \
|| fail 'capture has no frame-ready gate'
grep -qF 'property bool recordingReady: false' "$thumbnail" \
|| fail 'capture readiness is not tracked independently from captured content'
grep -qF 'if (!shot.recordingReady)' "$thumbnail" \
|| fail 'capture can start before the frame-ready gate'
grep -qF 'frameReady.restart();' "$thumbnail" \
|| fail 'a pending capture is not scheduled by the frame-ready gate'
grep -qF 'shot.recordingReady = true;' "$thumbnail" \
|| fail 'the frame-ready signal never releases capture'
capture_calls="$(grep -cF 'shot.captureFrame()' "$thumbnail")"
[[ "$capture_calls" -eq 1 ]] \
|| fail "capture must have one bounded attempt after readiness, found $capture_calls"
! grep -qF 'captureAttempts' "$thumbnail" \
|| fail 'blind capture retry state remains'
! grep -qF 'captureRetry' "$thumbnail" \
|| fail 'blind capture retry timer remains'
# The application icon remains visible until the one-shot capture succeeds.
grep -qF 'opacity: shotLoader.hasFrame ? 0 : 1' "$thumbnail" \
|| fail 'app-icon fallback no longer remains visible before a frame arrives'
printf 'overview thumbnail contract: PASS (frame-gated one-shot capture with icon fallback)\n'
+30
View File
@@ -16,6 +16,9 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
modules="$repo_dir/config/dot/quickshell/modules"
dock_menu="$modules/dock/DockContextMenu.qml"
notification_card="$modules/notifications/NotificationCard.qml"
osd="$modules/osd/Osd.qml"
fail() {
printf 'settings jump contract: %s\n' "$1" >&2
@@ -47,4 +50,31 @@ for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do
|| fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert"
done
# These surfaces do not have a bar-style secondary-click signal. Their
# contextual affordances must retain the original interaction and route to the
# setting page that owns the controls.
[[ -r "$dock_menu" ]] || fail 'dock has no contextual menu, so application actions cannot keep a final Dock settings action'
grep -qF 'ShellState.openSettings("desktop")' "$dock_menu" \
|| fail 'dock context menu does not open Desktop settings'
grep -qF 'entry.actions' "$dock_menu" \
|| fail 'dock context menu dropped application actions'
actions_line="$(grep -nF 'entry.actions' "$dock_menu" | head -1 | cut -d: -f1)"
settings_line="$(grep -nF 'Dock settings' "$dock_menu" | head -1 | cut -d: -f1)"
[[ -n "$actions_line" && -n "$settings_line" && "$actions_line" -lt "$settings_line" ]] \
|| fail 'Dock settings is not the final contextual action after application actions'
grep -qF 'Notification settings' "$notification_card" \
|| fail 'notification card has no overflow Settings action'
grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \
|| fail 'notification overflow does not open Notifications settings'
grep -qF 'Popover {' "$notification_card" \
|| fail 'notification Settings action is not contained in an overflow menu'
grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \
|| fail 'OSD does not accept its contextual secondary click'
grep -qF 'ShellState.openSettings("accessibility")' "$osd" \
|| fail 'OSD secondary click does not open Accessibility settings'
grep -qF 'OsdState.hide()' "$osd" \
|| fail 'OSD remains visible after its Settings jump'
printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"
+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'
+46 -2
View File
@@ -4,6 +4,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml"
scanner="$repo_dir/config/dot/quickshell/scripts/panama-wallpaper-scan"
fixture="$(mktemp -d /tmp/panama-wallpaper-service.XXXXXX)"
config_home="$fixture/config"
state_home="$fixture/state"
@@ -15,6 +16,10 @@ active_state="$fixture/active.json"
control="$fixture/control"
shell_log="$fixture/quickshell.log"
harness_pid=""
quoted_home="$fixture/home's"
quoted_wallpaper="$quoted_home/Pictures/Wallpapers/quoted.jpg"
bulk_wallpapers="$fixture/bulk-wallpapers"
bulk_segment="wallpaper-root-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
fail() {
printf 'wallpaper service contract: %s\n' "$1" >&2
@@ -46,9 +51,34 @@ cleanup() {
}
trap cleanup EXIT
mkdir -p "$config_home/panama" "$state_home" "$test_bin"
for _ in $(seq 1 15); do
bulk_wallpapers="$bulk_wallpapers/$bulk_segment"
done
mkdir -p "$config_home/panama" "$state_home" "$test_bin" \
"$quoted_home/Pictures/Wallpapers" "$bulk_wallpapers"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
printf '%s\n' '{}' >"$config_home/panama/settings.json"
printf 'quoted wallpaper\n' >"$quoted_wallpaper"
for index in $(seq 1 401); do
wallpaper="$bulk_wallpapers/wallpaper-$index.jpg"
printf 'bulk wallpaper %s\n' "$index" >"$wallpaper"
touch -d "@$((1700000000 + index))" "$wallpaper"
done
if ! bulk_scan="$("$scanner" "$bulk_wallpapers")"; then
fail 'a normal scan with more than 60 images exited non-zero'
fi
expected_bulk_scan="$(for index in $(seq 401 -1 342); do
printf '%s\n' "$bulk_wallpapers/wallpaper-$index.jpg"
done)"
[[ "$bulk_scan" == "$expected_bulk_scan" ]] \
|| fail "over-cap scan did not emit exactly the newest 60 images: $bulk_scan"
cat >"$config_home/panama/settings.json" <<'JSON'
{
"displays": {
"DP-2": {"mode":"[email protected]","scale":1.5,"transform":0,"x":0,"y":0,"primary":true},
"HDMI-A-1": {"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":0,"primary":false}
}
}
JSON
printf '%s\n' '{}' >"$active_state"
: >"$command_log"
: >"$control"
@@ -92,6 +122,7 @@ qs_for_harness() {
XDG_CONFIG_HOME="$config_home"
XDG_STATE_HOME="$state_home"
PATH="$test_bin:$PATH"
HOME="$quoted_home"
PANAMA_WALLPAPER_COMMAND_LOG="$command_log"
PANAMA_WALLPAPER_ACTIVE_STATE="$active_state"
PANAMA_WALLPAPER_CONTROL="$control"
@@ -113,6 +144,18 @@ wait_idle() {
fail "wallpaper transaction did not settle: $status"
}
wait_for_scan() {
local status=""
for _ in $(seq 1 80); do
status="$(qs_for_harness ipc call wallpaper-service-test status)"
jq -e --arg path "$quoted_wallpaper" \
'.scanning == false and (.available | index($path) != null)' \
<<<"$status" >/dev/null && return
sleep 0.1
done
fail "argument-safe wallpaper scan did not discover the quoted HOME image: $status"
}
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated service harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
@@ -123,6 +166,7 @@ for _ in $(seq 1 60); do
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated service harness process did not start'
wait_for_scan
wait_idle >/dev/null
: >"$command_log"
@@ -68,6 +68,15 @@ done
[[ "$(qs_for_harness ipc call wallpaper-settings-test activate per-monitor /images/c.jpg)" \
== '["assign:DP-2:/images/c.jpg"]' ]] || fail 'Per-display mode did not assign the selected output'
qs_for_harness ipc call wallpaper-settings-test selectOutput HDMI-A-1 >/dev/null
qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2|HDMI-A-1' >/dev/null
[[ "$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" == 'HDMI-A-1' ]] \
|| fail 'a valid per-monitor selection was reset during topology reconciliation'
qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2' >/dev/null
selected_after_disconnect="$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)"
[[ "$selected_after_disconnect" == 'DP-2' ]] \
|| fail "a disconnected per-monitor selection did not fall back to the primary output: $selected_after_disconnect"
states="$(qs_for_harness ipc call wallpaper-settings-test states)"
jq -e '.selectedOutput == "DP-2"
and .single == {"current":true,"selected":true}