diff --git a/config/dot/quickshell/accent-controls-harness.qml b/config/dot/quickshell/accent-controls-harness.qml new file mode 100644 index 0000000..79e212c --- /dev/null +++ b/config/dot/quickshell/accent-controls-harness.qml @@ -0,0 +1,41 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.settings +import qs.services + +ShellRoot { + Item { + width: 680 + height: editor.implicitHeight + + AccentEditor { + id: editor + width: parent.width + } + + ThemeProfilePicker { + width: parent.width + visible: false + } + + AccentPicker { + width: parent.width + visible: false + } + } + + IpcHandler { + target: "accent-controls-test" + + function status(): string { + return JSON.stringify(ThemeProfiles.activeProfile); + } + + function adjust(target: string, channel: string, ratio: real): string { + editor.changeChannel(target, channel, ratio); + return status(); + } + } +} diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index e92e226..cd7b4fc 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -975,6 +975,19 @@ Singleton { { value: "slate", label: "Slate" } ] }, + { + key: "themeProfileId", type: "string", def: "moon", group: "appearance", + pattern: "^[a-z0-9][a-z0-9-]{0,63}$", + label: "Selected theme profile", + detail: "The shipped or saved theme currently applied to the desktop", + internal: true + }, + { + key: "themeProfiles", type: "json", def: [], group: "appearance", + label: "Saved theme profiles", + detail: "Named custom colour schemes and accent pairs", + internal: true + }, // ── Application themes ───────────────────────────────────────────── // ColorScheme owns GTK's light/dark theme. These are the two theme diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index 882da56..e54d8d1 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -14,6 +14,7 @@ import Quickshell // QtQuick is required even though nothing visual is declared here: `color` is a // QtQuick value type, and Qt.rgba() lives in its JS namespace. import QtQuick +import qs.services Singleton { id: root @@ -60,30 +61,20 @@ Singleton { // Blue is the shipped Prism -- blue leading, orchid following -- and stays // the default. // - // `gnome` is the nearest member of GNOME's own accent-color enum, which is - // a fixed list of nine we do not get to extend. It is what libadwaita + // The table itself lives in services/ThemeProfileModel.js so a curated + // accent and a custom profile are the same kind of record. Its `gnome` + // member is the nearest name in GNOME's own accent-color enum, which is a + // fixed list of nine we do not get to extend; it is what libadwaita // applications -- Files, Papers, Loupe -- are told to use, so choosing an // accent here recolors them too instead of leaving them in GNOME blue. - // Nearest by hue, not by name: "rose" maps to red rather than pink because - // it is the red role in this palette. - readonly property var accents: ({ - "blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" }, - "orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" }, - "teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" }, - "green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" }, - "amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" }, - "orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" }, - "rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" }, - "slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" } - }) + readonly property var accents: ThemeProfiles.curatedAccents + readonly property var activeProfile: ThemeProfiles.activeProfile - // Falls back to blue for an unknown name, so a settings file written by a - // newer Panama -- or edited by hand -- degrades to the shipped identity - // rather than to an undefined color. - readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"] - - readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light - readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary + // ThemeProfiles validates every persisted record before it can become + // active, so these bindings are both reactive and safe to expose as the + // shell-wide colour roles. + readonly property color accent: root.activeProfile.accent + readonly property color accentSecondary: root.activeProfile.secondary readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue readonly property color cyan: root.dark ? "#86e1fc" : "#007197" readonly property color teal: root.dark ? "#4fd6be" : "#118c74" diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 780580f..9095478 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -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); + } } } diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index d476b29..352c60f 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -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 + } } diff --git a/config/dot/quickshell/modules/dock/DockBody.qml b/config/dot/quickshell/modules/dock/DockBody.qml index 5aa097f..7b462c0 100644 --- a/config/dot/quickshell/modules/dock/DockBody.qml +++ b/config/dot/quickshell/modules/dock/DockBody.qml @@ -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) } } } diff --git a/config/dot/quickshell/modules/dock/DockContextMenu.qml b/config/dot/quickshell/modules/dock/DockContextMenu.qml new file mode 100644 index 0000000..81ff24c --- /dev/null +++ b/config/dot/quickshell/modules/dock/DockContextMenu.qml @@ -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; + } + } + } + } +} diff --git a/config/dot/quickshell/modules/dock/DockItem.qml b/config/dot/quickshell/modules/dock/DockItem.qml index 26a46de..5685689 100644 --- a/config/dot/quickshell/modules/dock/DockItem.qml +++ b/config/dot/quickshell/modules/dock/DockItem.qml @@ -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 diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml index 6b1c5f3..966ed8d 100644 --- a/config/dot/quickshell/modules/notifications/NotificationCard.qml +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -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 diff --git a/config/dot/quickshell/modules/osd/Osd.qml b/config/dot/quickshell/modules/osd/Osd.qml index 0aa43db..0446d92 100644 --- a/config/dot/quickshell/modules/osd/Osd.qml +++ b/config/dot/quickshell/modules/osd/Osd.qml @@ -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 { diff --git a/config/dot/quickshell/modules/overview/WindowThumbnail.qml b/config/dot/quickshell/modules/overview/WindowThumbnail.qml index 4bff7ca..09534ad 100644 --- a/config/dot/quickshell/modules/overview/WindowThumbnail.qml +++ b/config/dot/quickshell/modules/overview/WindowThumbnail.qml @@ -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(); } } diff --git a/config/dot/quickshell/modules/settings/AccentEditor.qml b/config/dot/quickshell/modules/settings/AccentEditor.qml new file mode 100644 index 0000000..93ca53d --- /dev/null +++ b/config/dot/quickshell/modules/settings/AccentEditor.qml @@ -0,0 +1,218 @@ +// Advanced accent editing remains a labelled, keyboard-operable extension of +// the named fast path. Hue is always accompanied by saturation, value, a +// numeric readout, and the two-colour preview supplied by Appearance. + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.services +import qs.widgets +import "../../services/ThemeProfileModel.js" as ThemeProfileModel + +Column { + id: editor + + width: parent ? parent.width : 620 + spacing: 0 + + property string pickerTarget: "primary" + property string lastError: "" + + readonly property var primaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 }) + readonly property var secondaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 }) + + function changeChannel(target: string, channel: string, ratio: real): void { + const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv; + const next = { h: source.h, s: source.s, v: source.v }; + next[channel] = Math.round(Math.max(0, Math.min(1, ratio)) + * (channel === "h" ? 360 : 100)); + const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v); + const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent; + const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + function pick(target: string): void { + if (screenPicker.running) + return; + editor.pickerTarget = target; + editor.lastError = ""; + screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]); + } + + function acceptPicked(value: string): void { + const picked = String(value).trim().toLowerCase(); + if (ThemeProfileModel.hexToHsv(picked) === null) { + editor.lastError = "The sampled colour was not valid."; + return; + } + const primary = editor.pickerTarget === "primary" + ? picked : ThemeProfiles.activeProfile.accent; + const secondary = editor.pickerTarget === "secondary" + ? picked : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + component HsvRow: SettingRow { + id: root + + required property string target + required property string channel + required property real channelValue + required property real channelMaximum + property string suffix: "%" + + controlWidth: 280 + + Item { + id: keyboardSlider + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 270 + height: 32 + activeFocusOnTab: true + + Accessible.role: Accessible.Slider + Accessible.name: root.label + Accessible.description: Math.round(root.channelValue) + root.suffix + + ", range 0 to " + root.channelMaximum + Accessible.focusable: true + Accessible.focused: activeFocus + Accessible.onIncreaseAction: keyboardSlider.step(1) + Accessible.onDecreaseAction: keyboardSlider.step(-1) + + function step(direction: int): void { + const increment = root.channel === "h" ? 1 : 1; + const value = Math.max(0, Math.min(root.channelMaximum, + root.channelValue + direction * increment)); + editor.changeChannel(root.target, root.channel, value / root.channelMaximum); + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) { + keyboardSlider.step(-1); + event.accepted = true; + } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) { + keyboardSlider.step(1); + event.accepted = true; + } else if (event.key === Qt.Key_Home) { + editor.changeChannel(root.target, root.channel, 0); + event.accepted = true; + } else if (event.key === Qt.Key_End) { + editor.changeChannel(root.target, root.channel, 1); + event.accepted = true; + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: readout.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + height: 24 + radius: 9 + color: "transparent" + border.width: keyboardSlider.activeFocus ? 2 : 1 + border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08) + + ValueSlider { + anchors.fill: parent + anchors.margins: 4 + value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0 + onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio) + } + } + + Text { + id: readout + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 56 + horizontalAlignment: Text.AlignRight + text: Math.round(root.channelValue) + root.suffix + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + } + + HsvRow { + target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees" + } + HsvRow { + target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100 + label: "Primary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100 + label: "Primary value"; detail: "Brightness from black to full colour" + } + HsvRow { + target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient" + } + HsvRow { + target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100 + label: "Secondary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100 + label: "Secondary value"; detail: "Brightness from black to full colour" + } + + SettingRow { + label: "Pick colour from screen" + detail: editor.lastError !== "" + ? editor.lastError + : "Sample either end of the accent gradient with hyprpicker" + divider: false + controlWidth: 330 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + SettingsButton { + text: "Pick primary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("primary") + Keys.onReturnPressed: if (enabled) editor.pick("primary") + Keys.onSpacePressed: if (enabled) editor.pick("primary") + } + + SettingsButton { + text: "Pick secondary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("secondary") + Keys.onReturnPressed: if (enabled) editor.pick("secondary") + Keys.onSpacePressed: if (enabled) editor.pick("secondary") + } + } + } + + Process { + id: screenPicker + + stdout: StdioCollector { + onStreamFinished: editor.acceptPicked(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + editor.lastError = "Screen colour picking was cancelled or unavailable."; + } + } +} diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml index cb7ec8f..7784333 100644 --- a/config/dot/quickshell/modules/settings/AccentPicker.qml +++ b/config/dot/quickshell/modules/settings/AccentPicker.qml @@ -18,7 +18,7 @@ Flow { spacing: 10 - readonly property string current: DesktopPreferences.get("accentName") || "blue" + readonly property string current: ThemeProfiles.activeAccentName // The schema's own option list, not Theme.accents directly -- two lists // hand-kept in sync is how they drift. This is the same pattern @@ -60,6 +60,8 @@ Flow { } Rectangle { + id: swatch + width: 46 height: 46 radius: 23 @@ -67,8 +69,25 @@ Flow { color: "transparent" // The ring sits outside the gradient rather than over it, so a // selected swatch still shows its true colors. - border.width: entry.selected ? 2 : 1 - border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14) + border.width: swatch.activeFocus || entry.selected ? 2 : 1 + border.color: swatch.activeFocus + ? Theme.accentSecondary + : (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)) + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: entry.pair.label + " accent" + Accessible.description: String(entry.start) + " to " + String(entry.end) + Accessible.focusable: true + Accessible.focused: activeFocus + + function choose(): void { + if (!ThemeProfiles.useCuratedAccent(entry.modelData)) + console.warn("AccentPicker: accent was rejected", entry.modelData); + } + + Keys.onReturnPressed: swatch.choose() + Keys.onSpacePressed: swatch.choose() Rectangle { anchors.fill: parent @@ -81,6 +100,9 @@ Flow { GradientStop { position: 1.0; color: entry.end } } } + + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: swatch.choose() } } // Always shown, not a tooltip. Telling swatches apart by color is diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 720171c..de6ab49 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -129,18 +129,26 @@ SettingsPage { SettingsCard { visible: root.tab === "theme" - title: "Color scheme" + // Not "Color scheme" any more: the card holds the saved themes as well + // as the scheme, and a theme carries both ends of the accent with it. + title: "Theme" subtitle: ColorScheme.lastError !== "" ? ColorScheme.lastError - : "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow." + : "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent." - ChoiceRow { setting: "colorScheme" } + ThemeProfilePicker { + width: parent.width + } // Drawn as the gradient each accent produces rather than a flat dot, // because the gradient is what is being chosen. AccentPicker { width: parent.width } + + AccentEditor { + width: parent.width + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 0f9987e..7164491 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -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 })) diff --git a/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml new file mode 100644 index 0000000..db68d14 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml @@ -0,0 +1,123 @@ +// Named themes are the first layer of Appearance: shipped profiles stay +// immutable, while saved profiles can be selected or removed in place. + +import QtQuick +import qs.config +import qs.services + +Column { + id: root + + width: parent ? parent.width : 620 + spacing: 0 + + Repeater { + model: ThemeProfiles.profiles + + SettingRow { + id: profileRow + + required property var modelData + + label: profileRow.modelData.name + detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark") + + " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary + controlWidth: profileRow.modelData.shipped ? 88 : 170 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + SettingsButton { + id: useButton + text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use" + enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1) + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + } + + SettingsButton { + id: deleteButton + visible: !profileRow.modelData.shipped + text: "Delete" + activeFocusOnTab: visible + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + } + } + } + } + + SettingRow { + label: "Save current theme" + detail: "Stores this scheme and accent pair under a unique name" + divider: false + controlWidth: 292 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: 196 + height: 31 + radius: 8 + color: Theme.alpha(Theme.fg, 0.05) + border.width: nameInput.activeFocus ? 2 : 1 + border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12) + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + activeFocusOnTab: true + verticalAlignment: TextInput.AlignVCenter + maximumLength: 40 + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.35) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + Accessible.name: "Theme profile name" + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: nameInput.text === "" && !nameInput.activeFocus + text: "Theme name" + color: Theme.fgMuted + font: nameInput.font + } + + onAccepted: saveButton.save() + } + } + + SettingsButton { + id: saveButton + text: "Save" + tone: "accent" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 0 + border.color: activeFocus ? Theme.fg : "transparent" + + function save(): void { + if (ThemeProfiles.saveProfile(nameInput.text)) + nameInput.text = ""; + } + + onClicked: save() + Keys.onReturnPressed: save() + Keys.onSpacePressed: save() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/WallpaperControls.qml b/config/dot/quickshell/modules/settings/WallpaperControls.qml index a3eaf10..e33aa65 100644 --- a/config/dot/quickshell/modules/settings/WallpaperControls.qml +++ b/config/dot/quickshell/modules/settings/WallpaperControls.qml @@ -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" diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 3275adf..183f6de 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml AccentPicker 1.0 AccentPicker.qml TextFieldRow 1.0 TextFieldRow.qml +AccentEditor 1.0 AccentEditor.qml +ThemeProfilePicker 1.0 ThemeProfilePicker.qml diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 4d4a837..ed62d1a 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -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() { diff --git a/config/dot/quickshell/scripts/panama-wallpaper-scan b/config/dot/quickshell/scripts/panama-wallpaper-scan new file mode 100755 index 0000000..8f8daee --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wallpaper-scan @@ -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' diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index bf95585..2cb8a22 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -93,6 +93,14 @@ Singleton { function onRevisionChanged(): void { coalesce.restart(); } } + // Profile selection and custom edits both update preferences, but binding + // directly to the effective profile also documents the in-shell ownership + // boundary and keeps this propagation reactive if that storage changes. + Connections { + target: ThemeProfiles + function onActiveProfileChanged(): void { coalesce.restart(); } + } + Timer { id: coalesce interval: 250 diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index a094af1..ef2fd2f 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -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; diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 553a257..c85d72e 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -136,7 +136,10 @@ Singleton { { label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }, { label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" }, { label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" }, - { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" } + { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }, + { label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" }, + { label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" }, + { label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" } ] function pageFor(group: string): string { diff --git a/config/dot/quickshell/services/ThemeProfileModel.js b/config/dot/quickshell/services/ThemeProfileModel.js new file mode 100644 index 0000000..ed0a213 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfileModel.js @@ -0,0 +1,419 @@ +var MAX_NAME_LENGTH = 40; + +var SHIPPED = [ + { + id: "moon", + name: "Moon", + scheme: "dark", + accent: "#82aaff", + secondary: "#b172b0", + shipped: true + }, + { + id: "moon-rose", + name: "Moon Rose", + scheme: "dark", + accent: "#ff757f", + secondary: "#c099ff", + shipped: true + }, + { + id: "day", + name: "Day", + scheme: "light", + accent: "#2e7de9", + secondary: "#9854f1", + shipped: true + } +]; + +// The curated accents. `gnome` is the nearest member of GNOME's own +// accent-color enum, which is a fixed list of nine we do not get to extend. It +// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so +// choosing an accent here recolors them too instead of leaving them in GNOME +// blue. Nearest by hue, not by name: "rose" maps to red rather than pink +// because it is the red role in this palette. adwaita-accent-contract reads +// this table and fails when a member is missing or is not in GNOME's enum. +var CURATED = { + blue: { + dark: "#82aaff", darkSecondary: "#b172b0", + light: "#2e7de9", lightSecondary: "#9854f1", + label: "Prism blue", + gnome: "blue" + }, + orchid: { + dark: "#c099ff", darkSecondary: "#fca7ea", + light: "#7847bd", lightSecondary: "#9854f1", + label: "Orchid", + gnome: "purple" + }, + teal: { + dark: "#86e1fc", darkSecondary: "#82aaff", + light: "#007197", lightSecondary: "#2e7de9", + label: "Teal", + gnome: "teal" + }, + green: { + dark: "#c3e88d", darkSecondary: "#86e1fc", + light: "#587539", lightSecondary: "#007197", + label: "Green", + gnome: "green" + }, + amber: { + dark: "#ffc777", darkSecondary: "#ff966c", + light: "#8c6c3e", lightSecondary: "#b15c00", + label: "Amber", + gnome: "yellow" + }, + orange: { + dark: "#ff966c", darkSecondary: "#ff757f", + light: "#b15c00", lightSecondary: "#c64343", + label: "Orange", + gnome: "orange" + }, + rose: { + dark: "#ff757f", darkSecondary: "#c099ff", + light: "#f52a65", lightSecondary: "#9854f1", + label: "Rose", + gnome: "red" + }, + slate: { + dark: "#828bb8", darkSecondary: "#82aaff", + light: "#6172b0", lightSecondary: "#2e7de9", + label: "Slate", + gnome: "slate" + } +}; + +function copyProfile(profile) { + return { + id: profile.id, + name: profile.name, + scheme: profile.scheme, + accent: profile.accent, + secondary: profile.secondary, + shipped: profile.shipped === true + }; +} + +function shippedProfiles() { + return SHIPPED.map(copyProfile); +} + +function curatedAccents() { + var result = {}; + Object.keys(CURATED).forEach(function(name) { + result[name] = Object.assign({}, CURATED[name]); + }); + return result; +} + +function isScheme(value) { + return value === "dark" || value === "light"; +} + +function normalizedColor(value) { + var color = String(value || "").trim().toLowerCase(); + return /^#[0-9a-f]{6}$/.test(color) ? color : null; +} + +function normalizeStoredProfile(value) { + if (!value || typeof value !== "object" || value.shipped === true) + return null; + + var id = String(value.id || "").trim(); + var name = String(value.name || "").trim(); + var accent = normalizedColor(value.accent); + var secondary = normalizedColor(value.secondary); + if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id) + || name.length === 0 || name.length > MAX_NAME_LENGTH + || !isScheme(value.scheme) || !accent || !secondary) + return null; + + return { + id: id, + name: name, + scheme: value.scheme, + accent: accent, + secondary: secondary, + shipped: false + }; +} + +function validCustomProfiles(values) { + if (!Array.isArray(values)) + return []; + + var ids = {}; + var names = {}; + SHIPPED.forEach(function(profile) { + ids[profile.id] = true; + names[profile.name.toLowerCase()] = true; + }); + + var result = []; + values.forEach(function(value) { + var profile = normalizeStoredProfile(value); + if (!profile) + return; + var foldedName = profile.name.toLowerCase(); + if (ids[profile.id] || names[foldedName]) + return; + ids[profile.id] = true; + names[foldedName] = true; + result.push(profile); + }); + return result; +} + +function profileCatalog(values) { + return shippedProfiles().concat(validCustomProfiles(values)); +} + +function boundedName(value) { + var name = String(value || "").trim(); + if (!name) + name = "Custom theme"; + return name.slice(0, MAX_NAME_LENGTH); +} + +function uniqueName(value, profiles) { + var requested = boundedName(value); + var names = {}; + profiles.forEach(function(profile) { + names[profile.name.toLowerCase()] = true; + }); + if (!names[requested.toLowerCase()]) + return requested; + + for (var suffix = 2; suffix < 10000; suffix++) { + var ending = " " + suffix; + var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending; + if (!names[candidate.toLowerCase()]) + return candidate; + } + return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000"; +} + +function slug(value) { + var result = String(value || "").toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + .replace(/-+$/g, ""); + return result || "theme"; +} + +function uniqueId(name, profiles) { + var base = "custom-" + slug(name); + var ids = {}; + profiles.forEach(function(profile) { ids[profile.id] = true; }); + if (!ids[base]) + return base; + for (var suffix = 2; suffix < 10000; suffix++) { + var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix; + if (!ids[candidate]) + return candidate; + } + return base.slice(0, 52) + "-10000"; +} + +function createCustomProfile(values, input) { + var customs = validCustomProfiles(values); + var catalog = shippedProfiles().concat(customs); + var scheme = input && input.scheme; + var accent = normalizedColor(input && input.accent); + var secondary = normalizedColor(input && input.secondary); + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + var name = uniqueName(input && input.name, catalog); + var profile = { + id: uniqueId(name, catalog), + name: name, + scheme: scheme, + accent: accent, + secondary: secondary, + shipped: false + }; + return { profiles: customs.concat([profile]), profile: profile }; +} + +function findProfile(values, id) { + var catalog = profileCatalog(values); + for (var index = 0; index < catalog.length; index++) { + if (catalog[index].id === id) + return catalog[index]; + } + return null; +} + +function editProfile(values, selected, changes) { + var customs = validCustomProfiles(values); + if (!selected || typeof selected !== "object") + return { profiles: customs, profile: null }; + + var accent = normalizedColor(changes && changes.accent !== undefined + ? changes.accent : selected.accent); + var secondary = normalizedColor(changes && changes.secondary !== undefined + ? changes.secondary : selected.secondary); + var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme; + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + if (selected.shipped === true) { + return createCustomProfile(customs, { + name: selected.name + " custom", + scheme: scheme, + accent: accent, + secondary: secondary + }); + } + + var stored = normalizeStoredProfile(selected); + if (!stored) + return { profiles: customs, profile: null }; + + var updated = Object.assign({}, stored, { + scheme: scheme, + accent: accent, + secondary: secondary + }); + var found = false; + var next = customs.map(function(profile) { + if (profile.id !== updated.id) + return profile; + found = true; + return updated; + }); + if (!found) + next.push(updated); + return { profiles: next, profile: updated }; +} + +function deleteProfile(values, id) { + var customs = validCustomProfiles(values); + for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) { + if (SHIPPED[shippedIndex].id === id) + return { profiles: customs, removed: false }; + } + var next = customs.filter(function(profile) { return profile.id !== id; }); + return { profiles: next, removed: next.length !== customs.length }; +} + +function curatedPair(name, scheme) { + var entry = CURATED[name] || CURATED.blue; + if (scheme === "light") + return { accent: entry.light, secondary: entry.lightSecondary }; + return { accent: entry.dark, secondary: entry.darkSecondary }; +} + +function matchingShippedProfile(scheme, accent, secondary) { + var first = normalizedColor(accent); + var second = normalizedColor(secondary); + for (var index = 0; index < SHIPPED.length; index++) { + var profile = SHIPPED[index]; + if (profile.scheme === scheme && profile.accent === first && profile.secondary === second) + return copyProfile(profile); + } + return null; +} + +function curatedNameForProfile(profile) { + if (!profile || !isScheme(profile.scheme)) + return ""; + var accent = normalizedColor(profile.accent); + var secondary = normalizedColor(profile.secondary); + var names = Object.keys(CURATED); + for (var index = 0; index < names.length; index++) { + var name = names[index]; + var pair = curatedPair(name, profile.scheme); + if (pair.accent === accent && pair.secondary === secondary) + return name; + } + return ""; +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, Number(value))); +} + +function channelHex(value) { + var text = Math.round(value).toString(16); + return text.length < 2 ? "0" + text : text; +} + +function hsvToHex(hue, saturation, value) { + var h = Number(hue); + var s = Number(saturation); + var v = Number(value); + if (!isFinite(h) || !isFinite(s) || !isFinite(v)) + return null; + h = ((h % 360) + 360) % 360; + s = clamp(s, 0, 100) / 100; + v = clamp(v, 0, 100) / 100; + + var chroma = v * s; + var section = h / 60; + var x = chroma * (1 - Math.abs(section % 2 - 1)); + var red = 0; + var green = 0; + var blue = 0; + if (section < 1) { red = chroma; green = x; } + else if (section < 2) { red = x; green = chroma; } + else if (section < 3) { green = chroma; blue = x; } + else if (section < 4) { green = x; blue = chroma; } + else if (section < 5) { red = x; blue = chroma; } + else { red = chroma; blue = x; } + var match = v - chroma; + return "#" + channelHex((red + match) * 255) + + channelHex((green + match) * 255) + + channelHex((blue + match) * 255); +} + +function hexToHsv(value) { + var color = normalizedColor(value); + if (!color) + return null; + var red = parseInt(color.slice(1, 3), 16) / 255; + var green = parseInt(color.slice(3, 5), 16) / 255; + var blue = parseInt(color.slice(5, 7), 16) / 255; + var maximum = Math.max(red, green, blue); + var minimum = Math.min(red, green, blue); + var delta = maximum - minimum; + var hue = 0; + if (delta !== 0) { + if (maximum === red) + hue = 60 * (((green - blue) / delta) % 6); + else if (maximum === green) + hue = 60 * ((blue - red) / delta + 2); + else + hue = 60 * ((red - green) / delta + 4); + } + if (hue < 0) + hue += 360; + return { + h: Math.round(hue), + s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100), + v: Math.round(maximum * 100) + }; +} + +if (typeof module !== "undefined") { + module.exports = { + MAX_NAME_LENGTH: MAX_NAME_LENGTH, + shippedProfiles: shippedProfiles, + curatedAccents: curatedAccents, + validCustomProfiles: validCustomProfiles, + profileCatalog: profileCatalog, + createCustomProfile: createCustomProfile, + findProfile: findProfile, + editProfile: editProfile, + deleteProfile: deleteProfile, + curatedPair: curatedPair, + matchingShippedProfile: matchingShippedProfile, + curatedNameForProfile: curatedNameForProfile, + hsvToHex: hsvToHex, + hexToHsv: hexToHsv + }; +} diff --git a/config/dot/quickshell/services/ThemeProfiles.qml b/config/dot/quickshell/services/ThemeProfiles.qml new file mode 100644 index 0000000..24d7c48 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfiles.qml @@ -0,0 +1,108 @@ +pragma Singleton + +import Quickshell +import QtQuick +import qs.config +import "ThemeProfileModel.js" as ThemeProfileModel + +Singleton { + id: root + + readonly property var customProfiles: ThemeProfileModel.validCustomProfiles( + DesktopPreferences.get("themeProfiles")) + readonly property var curatedAccents: ThemeProfileModel.curatedAccents() + readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles) + readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon" + readonly property var activeProfile: ThemeProfileModel.findProfile( + root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0] + readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile( + root.activeProfile) + + property bool reconciling: false + + function commitActive(profile: var): bool { + if (!profile) + return false; + + // Scheme first: if another control changed it, the reconciliation hook + // may briefly select Day/Moon before this final stable profile id lands. + const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme); + const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id); + return schemeAccepted && profileAccepted; + } + + function selectProfile(id: string): bool { + return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id)); + } + + // Editing a shipped profile creates a saved custom copy. Editing a custom + // profile updates only that record. ThemeProfileModel enforces both rules, + // leaving this singleton responsible only for validated preference routing. + function setAccentPair(accent: string, secondary: string): bool { + const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, { + accent: accent, + secondary: secondary + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function saveProfile(name: string): bool { + const result = ThemeProfileModel.createCustomProfile(root.customProfiles, { + name: name, + scheme: root.activeProfile.scheme, + accent: root.activeProfile.accent, + secondary: root.activeProfile.secondary + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function deleteProfile(id: string): bool { + const result = ThemeProfileModel.deleteProfile(root.customProfiles, id); + if (!result.removed) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + if (root.activeId === id) + return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon"); + return true; + } + + function useCuratedAccent(name: string): bool { + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + const pair = ThemeProfileModel.curatedPair(name, scheme); + const shipped = ThemeProfileModel.matchingShippedProfile( + scheme, pair.accent, pair.secondary); + const accepted = shipped + ? root.commitActive(shipped) + : root.setAccentPair(pair.accent, pair.secondary); + if (accepted) + SystemSettings.commitPreference("accentName", name); + return accepted; + } + + function reconcileScheme(): void { + if (root.reconciling) + return; + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + if (root.activeProfile.scheme === scheme) + return; + root.reconciling = true; + SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon"); + root.reconciling = false; + } + + Component.onCompleted: root.reconcileScheme() + + Connections { + target: DesktopPreferences + function onRevisionChanged(): void { root.reconcileScheme(); } + } +} diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index e68db62..ec4f15e 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -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) diff --git a/config/dot/quickshell/theme-profiles-harness.qml b/config/dot/quickshell/theme-profiles-harness.qml new file mode 100644 index 0000000..b42e648 --- /dev/null +++ b/config/dot/quickshell/theme-profiles-harness.qml @@ -0,0 +1,40 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "theme-profiles-test" + + function status(): string { + return JSON.stringify({ + active: ThemeProfiles.activeProfile, + profiles: ThemeProfiles.profiles, + stored: DesktopPreferences.get("themeProfiles") + }); + } + + function select(id: string): bool { + return ThemeProfiles.selectProfile(id); + } + + function scheme(value: string): bool { + return SystemSettings.commitPreference("colorScheme", value); + } + + function edit(accent: string, secondary: string): bool { + return ThemeProfiles.setAccentPair(accent, secondary); + } + + function save(name: string): bool { + return ThemeProfiles.saveProfile(name); + } + + function remove(id: string): bool { + return ThemeProfiles.deleteProfile(id); + } + } +} diff --git a/config/dot/quickshell/wallpaper-service-harness.qml b/config/dot/quickshell/wallpaper-service-harness.qml index 57ef178..6fe92a8 100644 --- a/config/dot/quickshell/wallpaper-service-harness.qml +++ b/config/dot/quickshell/wallpaper-service-harness.qml @@ -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 }); } } diff --git a/config/dot/quickshell/wallpaper-settings-harness.qml b/config/dot/quickshell/wallpaper-settings-harness.qml index 57fcd4a..21be75b 100644 --- a/config/dot/quickshell/wallpaper-settings-harness.qml +++ b/config/dot/quickshell/wallpaper-settings-harness.qml @@ -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 = { diff --git a/docs/superpowers/plans/2026-08-18-roadmap-completion.md b/docs/superpowers/plans/2026-08-18-roadmap-completion.md new file mode 100644 index 0000000..1f5a32e --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-roadmap-completion.md @@ -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 3–5 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 service’s 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 profile’s 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 application’s 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 ` 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 repository’s 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. + diff --git a/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md b/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md new file mode 100644 index 0000000..b83514c --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md @@ -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. + diff --git a/tests/quickshell/accent-controls-contract b/tests/quickshell/accent-controls-contract new file mode 100755 index 0000000..ef0fa96 --- /dev/null +++ b/tests/quickshell/accent-controls-contract @@ -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' diff --git a/tests/quickshell/adwaita-accent-contract b/tests/quickshell/adwaita-accent-contract index 1192f90..4cc6c34 100755 --- a/tests/quickshell/adwaita-accent-contract +++ b/tests/quickshell/adwaita-accent-contract @@ -8,7 +8,8 @@ # inside Panama's own surfaces, which look correct either way. # # Three things have to line up, and none of them share a source: -# - config/Theme.qml carries the accent table with its `gnome` member +# - services/ThemeProfileModel.js carries the accent table with its `gnome` +# member (Theme.qml exposes it; the theme-profile system owns it) # - scripts/panama-theme-apps maps the same names in shell # - the portal routes Settings to a backend that can serve accent-color # @@ -17,7 +18,7 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -theme="$repo_dir/config/dot/quickshell/config/Theme.qml" +accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf" @@ -26,25 +27,24 @@ fail() { exit 1 } -for path in "$theme" "$script" "$portals"; do +for path in "$accents" "$script" "$portals"; do [[ -r "$path" ]] || fail "missing $path" done # ── The accent table ───────────────────────────────────────────────────────── -mapping="$(python3 - "$theme" <<'PYTHON' +mapping="$(python3 - "$accents" <<'PYTHON' import re, sys source = open(sys.argv[1]).read() -table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S) +table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S) if not table: - raise SystemExit("accents table not found") -for line in table.group(1).splitlines(): - name = re.search(r'"([a-z]+)":', line) - if not name: - continue - member = re.search(r'gnome: "([a-z]+)"', line) - print(name.group(1) + "\t" + (member.group(1) if member else "")) + raise SystemExit("curated accent table not found") +# Each accent is a multi-line record now, so the name opens a block and the +# member may be several lines below it. +for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S): + member = re.search(r'gnome: "([a-z]+)"', entry.group(2)) + print(entry.group(1) + "\t" + (member.group(1) if member else "")) PYTHON -)" || fail 'could not read the accent table from Theme.qml' +)" || fail 'could not read the curated accent table from ThemeProfileModel.js' [[ -n "$mapping" ]] || fail 'the accent table is empty' diff --git a/tests/quickshell/control-center-contract b/tests/quickshell/control-center-contract index 8f6829e..f11d210 100755 --- a/tests/quickshell/control-center-contract +++ b/tests/quickshell/control-center-contract @@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \ || fail 'Home tile does not give its dimmer an accessory-specific accessible name' +# Controls added after the original contract remain service-backed rather than +# becoming optimistic local toggles. Keep these checks in the static section so +# they can run without mapping the Control Center on a daily-driver desktop. +rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile control does not follow daemon availability' +rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \ + || fail 'opening Control Center does not refresh the external power profile' +rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices have no detail section' +rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices are not mounted' + +rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light control does not reflect service state' +rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light primary action is not wired' +rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light schedule action is not wired' + +rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control does not expose its current state' +rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control bypasses the preference commit path' + +if [[ "${1:-}" == "--static-only" ]]; then + trap - EXIT + cleanup + printf 'Control Center contract (static): PASS\n' + exit 0 +fi + cp -a "$source_config_path" "$config_path" : >"$helper_log" cat >"$config_path/scripts/panama-home-assistant" <<'EOF' diff --git a/tests/quickshell/display-transaction-contract b/tests/quickshell/display-transaction-contract index d13cf57..9d6d657 100755 --- a/tests/quickshell/display-transaction-contract +++ b/tests/quickshell/display-transaction-contract @@ -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" ]] \ diff --git a/tests/quickshell/displays-contract b/tests/quickshell/displays-contract index 6ea3364..52e7915 100755 --- a/tests/quickshell/displays-contract +++ b/tests/quickshell/displays-contract @@ -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' diff --git a/tests/quickshell/lock-screen-helper-contract b/tests/quickshell/lock-screen-helper-contract index c705f36..4e8b11d 100755 --- a/tests/quickshell/lock-screen-helper-contract +++ b/tests/quickshell/lock-screen-helper-contract @@ -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' diff --git a/tests/quickshell/osd-ui-contract b/tests/quickshell/osd-ui-contract index b600846..dc2119e 100755 --- a/tests/quickshell/osd-ui-contract +++ b/tests/quickshell/osd-ui-contract @@ -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' diff --git a/tests/quickshell/overview-thumbnail-contract b/tests/quickshell/overview-thumbnail-contract new file mode 100755 index 0000000..315b419 --- /dev/null +++ b/tests/quickshell/overview-thumbnail-contract @@ -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' diff --git a/tests/quickshell/settings-jump-contract b/tests/quickshell/settings-jump-contract index 02d356c..d591064 100755 --- a/tests/quickshell/settings-jump-contract +++ b/tests/quickshell/settings-jump-contract @@ -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" diff --git a/tests/quickshell/theme-profiles-contract b/tests/quickshell/theme-profiles-contract new file mode 100755 index 0000000..9520d16 --- /dev/null +++ b/tests/quickshell/theme-profiles-contract @@ -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' diff --git a/tests/quickshell/wallpaper-service-contract b/tests/quickshell/wallpaper-service-contract index 769ce4f..7ad1149 100755 --- a/tests/quickshell/wallpaper-service-contract +++ b/tests/quickshell/wallpaper-service-contract @@ -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":"4500x3000@60.00","scale":1.5,"transform":0,"x":0,"y":0,"primary":true}, + "HDMI-A-1": {"mode":"2560x1440@60.00","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" diff --git a/tests/quickshell/wallpaper-settings-contract b/tests/quickshell/wallpaper-settings-contract index 657122a..53cd3c7 100755 --- a/tests/quickshell/wallpaper-settings-contract +++ b/tests/quickshell/wallpaper-settings-contract @@ -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}