Merge the Panama roadmap completion work
Six commits, rebased onto main and integrated with where main had moved in the sixty-two commits since the branch was cut. What lands: a theme-profile system, so a curated accent and a saved custom theme become the same kind of record; a dock context menu; contextual Settings routes from the dock, notifications and the OSD; overview-thumbnail capture reliability; and wallpaper and lock-screen hardening -- which needed the shipped wallpaper that the fresh-install work made exist, and was the dependency that kept this branch parked. Where main had moved further along the same path, main won: the focused border driven by the chosen accent, the gradient built through the shared serializer, the multi-edge dock. The branch's work sits on top of those. The one thing that had to be carried across by hand was the `gnome` member on each accent, which is what recolors libadwaita applications; it moved into the curated table rather than being lost with the table it replaced. One behaviour change worth knowing: the OSD now accepts pointer input, where it previously accepted none. A secondary click on a visible OSD opens its settings, and a Wayland region cannot admit one button and refuse another. The region stays bounded to the OSD's own card. 124 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Item {
|
||||
width: 680
|
||||
height: editor.implicitHeight
|
||||
|
||||
AccentEditor {
|
||||
id: editor
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ThemeProfilePicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "accent-controls-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify(ThemeProfiles.activeProfile);
|
||||
}
|
||||
|
||||
function adjust(target: string, channel: string, ratio: real): string {
|
||||
editor.changeChannel(target, channel, ratio);
|
||||
return status();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +975,19 @@ Singleton {
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "themeProfileId", type: "string", def: "moon", group: "appearance",
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||
label: "Selected theme profile",
|
||||
detail: "The shipped or saved theme currently applied to the desktop",
|
||||
internal: true
|
||||
},
|
||||
{
|
||||
key: "themeProfiles", type: "json", def: [], group: "appearance",
|
||||
label: "Saved theme profiles",
|
||||
detail: "Named custom colour schemes and accent pairs",
|
||||
internal: true
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
|
||||
@@ -14,6 +14,7 @@ import Quickshell
|
||||
// QtQuick is required even though nothing visual is declared here: `color` is a
|
||||
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
|
||||
import QtQuick
|
||||
import qs.services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -60,30 +61,20 @@ Singleton {
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
//
|
||||
// `gnome` is the nearest member of GNOME's own accent-color enum, which is
|
||||
// a fixed list of nine we do not get to extend. It is what libadwaita
|
||||
// The table itself lives in services/ThemeProfileModel.js so a curated
|
||||
// accent and a custom profile are the same kind of record. Its `gnome`
|
||||
// member is the nearest name in GNOME's own accent-color enum, which is a
|
||||
// fixed list of nine we do not get to extend; it is what libadwaita
|
||||
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||
// Nearest by hue, not by name: "rose" maps to red rather than pink because
|
||||
// it is the red role in this palette.
|
||||
readonly property var accents: ({
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" }
|
||||
})
|
||||
readonly property var accents: ThemeProfiles.curatedAccents
|
||||
readonly property var activeProfile: ThemeProfiles.activeProfile
|
||||
|
||||
// Falls back to blue for an unknown name, so a settings file written by a
|
||||
// newer Panama -- or edited by hand -- degrades to the shipped identity
|
||||
// rather than to an undefined color.
|
||||
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
|
||||
|
||||
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
|
||||
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
|
||||
// ThemeProfiles validates every persisted record before it can become
|
||||
// active, so these bindings are both reactive and safe to expose as the
|
||||
// shell-wide colour roles.
|
||||
readonly property color accent: root.activeProfile.accent
|
||||
readonly property color accentSecondary: root.activeProfile.secondary
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
|
||||
@@ -6,6 +6,10 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The isolated screen model begins with both fixture outputs so changing
|
||||
// it below exercises the same reactive topology path as a real hotplug.
|
||||
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
|
||||
|
||||
IpcHandler {
|
||||
target: "displays-test"
|
||||
|
||||
@@ -42,6 +46,7 @@ ShellRoot {
|
||||
function transactionStatus(): string {
|
||||
return JSON.stringify({
|
||||
layout: Displays.currentLayout(),
|
||||
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
|
||||
pending: Displays.pendingRequestedLayout,
|
||||
previous: Displays.pendingPreviousLayout,
|
||||
reverting: Displays.revertExpectedLayout,
|
||||
@@ -141,5 +146,8 @@ ShellRoot {
|
||||
if (monitor) Displays.forget(monitor.name);
|
||||
}
|
||||
function refresh(): void { Displays.refresh(); }
|
||||
function setScreenModel(names: string): void {
|
||||
Displays.screenOverride = JSON.parse(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,12 @@ PanelWindow {
|
||||
DockBody {
|
||||
id: body
|
||||
|
||||
onContextMenuRequested: (anchorItem, entry) => {
|
||||
dockContextMenu.anchorItem = anchorItem;
|
||||
dockContextMenu.entry = entry;
|
||||
dockContextMenu.visible = true;
|
||||
}
|
||||
|
||||
vertical: root.vertical
|
||||
leftSide: root.position === "left"
|
||||
|
||||
@@ -260,4 +266,8 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DockContextMenu {
|
||||
id: dockContextMenu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ Rectangle {
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
signal contextMenuRequested(Item anchorItem, var entry)
|
||||
|
||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||
// of across it.
|
||||
@@ -170,6 +171,7 @@ Rectangle {
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
|
||||
// configuration route is deliberately last so it never displaces app actions.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.bar
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
property Item anchorItem: null
|
||||
property var entry: null
|
||||
|
||||
anchor.item: root.anchorItem
|
||||
anchor.edges: Edges.Top | Edges.Left
|
||||
anchor.gravity: Edges.Top | Edges.Right
|
||||
anchor.margins.bottom: 8
|
||||
|
||||
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
|
||||
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||
color: "transparent"
|
||||
visible: false
|
||||
grabFocus: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Column {
|
||||
id: menu
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
id: applicationActions
|
||||
model: root.entry ? root.entry.actions : []
|
||||
|
||||
delegate: TrayMenuRow {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: modelData.name
|
||||
onActivated: {
|
||||
modelData.execute();
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
anchors.margins: 3
|
||||
visible: applicationActions.count > 0
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: "Dock settings"
|
||||
onActivated: {
|
||||
ShellState.openSettings("desktop");
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ Item {
|
||||
// Emitted so DockBody can drive the single shared tooltip.
|
||||
signal entered
|
||||
signal exited
|
||||
signal contextMenuRequested
|
||||
|
||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||
// size so the row doesn't reflow.
|
||||
@@ -115,7 +116,7 @@ Item {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
@@ -126,6 +127,10 @@ Item {
|
||||
root.launch();
|
||||
return;
|
||||
}
|
||||
if (mev.button === Qt.RightButton) {
|
||||
root.contextMenuRequested();
|
||||
return;
|
||||
}
|
||||
if (root.running)
|
||||
root.focusNext();
|
||||
else
|
||||
|
||||
@@ -14,6 +14,7 @@ import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.modules.bar
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
@@ -127,11 +128,44 @@ Rectangle {
|
||||
onClicked: root.dismissed()
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: settingsMenuButton
|
||||
anchors.right: closeButton.left
|
||||
anchors.rightMargin: 2
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 6
|
||||
size: 24
|
||||
iconSize: 14
|
||||
tint: Theme.fgDim
|
||||
icon: "view-more-symbolic"
|
||||
iconFallback: "open-menu-symbolic"
|
||||
onClicked: settingsMenu.visible = !settingsMenu.visible
|
||||
}
|
||||
|
||||
Popover {
|
||||
id: settingsMenu
|
||||
anchorItem: settingsMenuButton
|
||||
|
||||
// A Column already measures itself from its children, and in Qt 6 both
|
||||
// implicit sizes are read-only on a positioner -- assigning them makes
|
||||
// the whole shell fail to load rather than just this menu.
|
||||
Column {
|
||||
TrayMenuRow {
|
||||
id: notificationSettings
|
||||
label: "Notification settings"
|
||||
onActivated: {
|
||||
ShellState.openSettings("notifications");
|
||||
settingsMenu.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
||||
anchors.leftMargin: appIcon.visible ? 10 : 14
|
||||
anchors.right: image.visible ? image.left : closeButton.left
|
||||
anchors.right: image.visible ? image.left : settingsMenuButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 12
|
||||
|
||||
@@ -34,7 +34,14 @@ PanelWindow {
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
color: "transparent"
|
||||
mask: Region {}
|
||||
mask: Region {
|
||||
item: inputMask
|
||||
}
|
||||
|
||||
Item {
|
||||
id: inputMask
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-osd"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
@@ -145,6 +152,17 @@ PanelWindow {
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: event => {
|
||||
if (event.button !== Qt.RightButton)
|
||||
return;
|
||||
ShellState.openSettings("accessibility");
|
||||
OsdState.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
|
||||
@@ -176,33 +176,33 @@ Item {
|
||||
height: aspect > 0 ? width / aspect : parent.height
|
||||
opacity: hasContent ? 1 : 0
|
||||
|
||||
// The capture context isn't ready the instant the layer surface is
|
||||
// told to become visible — it needs the surface to actually map,
|
||||
// which takes a frame or two. Calling captureFrame() before then
|
||||
// logs "no recording context is ready" and yields nothing, so
|
||||
// retry a few times and then give up quietly (the caption and app
|
||||
// icon are still shown, so a missing thumbnail is cosmetic).
|
||||
property int captureAttempts: 0
|
||||
// A ScreencopyView cannot tell us that its recording context is
|
||||
// ready before a capture. The enclosing overview gets that
|
||||
// context only after its first rendered frame, so defer the first
|
||||
// one-shot capture to that event. Retrying before then only emits
|
||||
// "no recording context is ready" warnings; if a capture later
|
||||
// cannot produce content, the app icon remains the fallback.
|
||||
property bool recordingReady: false
|
||||
|
||||
function tryCapture(): void {
|
||||
captureAttempts = 0;
|
||||
captureRetry.restart();
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
if (!shot.recordingReady) {
|
||||
frameReady.restart();
|
||||
return;
|
||||
}
|
||||
shot.captureFrame();
|
||||
}
|
||||
|
||||
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
|
||||
// resolve to the enclosing ScreencopyView.
|
||||
Timer {
|
||||
id: captureRetry
|
||||
interval: 80
|
||||
repeat: true
|
||||
FrameAnimation {
|
||||
id: frameReady
|
||||
running: false
|
||||
onTriggered: {
|
||||
if (shot.hasContent || shot.captureAttempts >= 6) {
|
||||
stop();
|
||||
running = false;
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
}
|
||||
shot.captureAttempts++;
|
||||
shot.captureFrame();
|
||||
shot.recordingReady = true;
|
||||
shot.tryCapture();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Advanced accent editing remains a labelled, keyboard-operable extension of
|
||||
// the named fast path. Hue is always accompanied by saturation, value, a
|
||||
// numeric readout, and the two-colour preview supplied by Appearance.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
import "../../services/ThemeProfileModel.js" as ThemeProfileModel
|
||||
|
||||
Column {
|
||||
id: editor
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
property string pickerTarget: "primary"
|
||||
property string lastError: ""
|
||||
|
||||
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
|
||||
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
|
||||
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
|
||||
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
|
||||
|
||||
function changeChannel(target: string, channel: string, ratio: real): void {
|
||||
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
|
||||
const next = { h: source.h, s: source.s, v: source.v };
|
||||
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
|
||||
* (channel === "h" ? 360 : 100));
|
||||
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
|
||||
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
|
||||
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
|
||||
ThemeProfiles.setAccentPair(primary, secondary);
|
||||
}
|
||||
|
||||
function pick(target: string): void {
|
||||
if (screenPicker.running)
|
||||
return;
|
||||
editor.pickerTarget = target;
|
||||
editor.lastError = "";
|
||||
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
|
||||
}
|
||||
|
||||
function acceptPicked(value: string): void {
|
||||
const picked = String(value).trim().toLowerCase();
|
||||
if (ThemeProfileModel.hexToHsv(picked) === null) {
|
||||
editor.lastError = "The sampled colour was not valid.";
|
||||
return;
|
||||
}
|
||||
const primary = editor.pickerTarget === "primary"
|
||||
? picked : ThemeProfiles.activeProfile.accent;
|
||||
const secondary = editor.pickerTarget === "secondary"
|
||||
? picked : ThemeProfiles.activeProfile.secondary;
|
||||
ThemeProfiles.setAccentPair(primary, secondary);
|
||||
}
|
||||
|
||||
component HsvRow: SettingRow {
|
||||
id: root
|
||||
|
||||
required property string target
|
||||
required property string channel
|
||||
required property real channelValue
|
||||
required property real channelMaximum
|
||||
property string suffix: "%"
|
||||
|
||||
controlWidth: 280
|
||||
|
||||
Item {
|
||||
id: keyboardSlider
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 270
|
||||
height: 32
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Slider
|
||||
Accessible.name: root.label
|
||||
Accessible.description: Math.round(root.channelValue) + root.suffix
|
||||
+ ", range 0 to " + root.channelMaximum
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: activeFocus
|
||||
Accessible.onIncreaseAction: keyboardSlider.step(1)
|
||||
Accessible.onDecreaseAction: keyboardSlider.step(-1)
|
||||
|
||||
function step(direction: int): void {
|
||||
const increment = root.channel === "h" ? 1 : 1;
|
||||
const value = Math.max(0, Math.min(root.channelMaximum,
|
||||
root.channelValue + direction * increment));
|
||||
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
|
||||
keyboardSlider.step(-1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
|
||||
keyboardSlider.step(1);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Home) {
|
||||
editor.changeChannel(root.target, root.channel, 0);
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_End) {
|
||||
editor.changeChannel(root.target, root.channel, 1);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: readout.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 24
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
border.width: keyboardSlider.activeFocus ? 2 : 1
|
||||
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
|
||||
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: readout
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 56
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round(root.channelValue) + root.suffix
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HsvRow {
|
||||
target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360
|
||||
suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees"
|
||||
}
|
||||
HsvRow {
|
||||
target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100
|
||||
label: "Primary saturation"; detail: "Colour intensity from grey to vivid"
|
||||
}
|
||||
HsvRow {
|
||||
target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100
|
||||
label: "Primary value"; detail: "Brightness from black to full colour"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360
|
||||
suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100
|
||||
label: "Secondary saturation"; detail: "Colour intensity from grey to vivid"
|
||||
}
|
||||
HsvRow {
|
||||
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
|
||||
label: "Secondary value"; detail: "Brightness from black to full colour"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Pick colour from screen"
|
||||
detail: editor.lastError !== ""
|
||||
? editor.lastError
|
||||
: "Sample either end of the accent gradient with hyprpicker"
|
||||
divider: false
|
||||
controlWidth: 330
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsButton {
|
||||
text: "Pick primary from screen"
|
||||
enabled: !screenPicker.running
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: editor.pick("primary")
|
||||
Keys.onReturnPressed: if (enabled) editor.pick("primary")
|
||||
Keys.onSpacePressed: if (enabled) editor.pick("primary")
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Pick secondary from screen"
|
||||
enabled: !screenPicker.running
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: editor.pick("secondary")
|
||||
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
|
||||
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: screenPicker
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: editor.acceptPicked(this.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
editor.lastError = "Screen colour picking was cancelled or unavailable.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Flow {
|
||||
|
||||
spacing: 10
|
||||
|
||||
readonly property string current: DesktopPreferences.get("accentName") || "blue"
|
||||
readonly property string current: ThemeProfiles.activeAccentName
|
||||
|
||||
// The schema's own option list, not Theme.accents directly -- two lists
|
||||
// hand-kept in sync is how they drift. This is the same pattern
|
||||
@@ -60,6 +60,8 @@ Flow {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: swatch
|
||||
|
||||
width: 46
|
||||
height: 46
|
||||
radius: 23
|
||||
@@ -67,8 +69,25 @@ Flow {
|
||||
color: "transparent"
|
||||
// The ring sits outside the gradient rather than over it, so a
|
||||
// selected swatch still shows its true colors.
|
||||
border.width: entry.selected ? 2 : 1
|
||||
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
|
||||
border.width: swatch.activeFocus || entry.selected ? 2 : 1
|
||||
border.color: swatch.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: entry.pair.label + " accent"
|
||||
Accessible.description: String(entry.start) + " to " + String(entry.end)
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: activeFocus
|
||||
|
||||
function choose(): void {
|
||||
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
|
||||
console.warn("AccentPicker: accent was rejected", entry.modelData);
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: swatch.choose()
|
||||
Keys.onSpacePressed: swatch.choose()
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -81,6 +100,9 @@ Flow {
|
||||
GradientStop { position: 1.0; color: entry.end }
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: swatch.choose() }
|
||||
}
|
||||
|
||||
// Always shown, not a tooltip. Telling swatches apart by color is
|
||||
|
||||
@@ -129,18 +129,26 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "theme"
|
||||
title: "Color scheme"
|
||||
// Not "Color scheme" any more: the card holds the saved themes as well
|
||||
// as the scheme, and a theme carries both ends of the accent with it.
|
||||
title: "Theme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
|
||||
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
ThemeProfilePicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
AccentEditor {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -22,7 +22,7 @@ SettingsPage {
|
||||
|
||||
property string selectedOutput: ""
|
||||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||
?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null)
|
||||
readonly property string currentMode: root.monitor
|
||||
? root.monitor.mode
|
||||
: ""
|
||||
@@ -38,7 +38,8 @@ SettingsPage {
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!Displays.monitorNamed(root.selectedOutput))
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
|
||||
? Displays.primaryFirstMonitors[0].name : "";
|
||||
}
|
||||
|
||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||
@@ -137,7 +138,7 @@ SettingsPage {
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Display"
|
||||
options: Displays.monitors.map(monitor => ({
|
||||
options: Displays.primaryFirstMonitors.map(monitor => ({
|
||||
value: monitor.name,
|
||||
label: monitor.description || monitor.name
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Named themes are the first layer of Appearance: shipped profiles stay
|
||||
// immutable, while saved profiles can be selected or removed in place.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: ThemeProfiles.profiles
|
||||
|
||||
SettingRow {
|
||||
id: profileRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
label: profileRow.modelData.name
|
||||
detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark")
|
||||
+ " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary
|
||||
controlWidth: profileRow.modelData.shipped ? 88 : 170
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use"
|
||||
enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: deleteButton
|
||||
visible: !profileRow.modelData.shipped
|
||||
text: "Delete"
|
||||
activeFocusOnTab: visible
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Save current theme"
|
||||
detail: "Stores this scheme and accent pair under a unique name"
|
||||
divider: false
|
||||
controlWidth: 292
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
width: 196
|
||||
height: 31
|
||||
radius: 8
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: nameInput.activeFocus ? 2 : 1
|
||||
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
TextInput {
|
||||
id: nameInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
activeFocusOnTab: true
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
maximumLength: 40
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
Accessible.name: "Theme profile name"
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: nameInput.text === "" && !nameInput.activeFocus
|
||||
text: "Theme name"
|
||||
color: Theme.fgMuted
|
||||
font: nameInput.font
|
||||
}
|
||||
|
||||
onAccepted: saveButton.save()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: saveButton
|
||||
text: "Save"
|
||||
tone: "accent"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: activeFocus ? Theme.fg : "transparent"
|
||||
|
||||
function save(): void {
|
||||
if (ThemeProfiles.saveProfile(nameInput.text))
|
||||
nameInput.text = "";
|
||||
}
|
||||
|
||||
onClicked: save()
|
||||
Keys.onReturnPressed: save()
|
||||
Keys.onSpacePressed: save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ Column {
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 4
|
||||
|
||||
onOutputsChanged: root.syncSelectedOutput()
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!root.outputs.includes(root.selectedOutput))
|
||||
root.selectedOutput = root.outputs.length > 0 ? root.outputs[0] : "";
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Wallpaper mode"
|
||||
|
||||
@@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
TextFieldRow 1.0 TextFieldRow.qml
|
||||
AccentEditor 1.0 AccentEditor.qml
|
||||
ThemeProfilePicker 1.0 ThemeProfilePicker.qml
|
||||
|
||||
@@ -17,8 +17,10 @@ status_file="$state_dir/hyprlock-status.json"
|
||||
fallback="$config_home/hypr/hyprlock.conf"
|
||||
temporary="$generated.tmp.$$"
|
||||
status_temporary="$status_file.tmp.$$"
|
||||
shipped_wallpaper="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
|
||||
|
||||
settings_valid=false
|
||||
wallpaper_warning=""
|
||||
|
||||
cleanup() {
|
||||
rm -f "$temporary" "$status_temporary" 2>/dev/null || true
|
||||
@@ -76,7 +78,23 @@ read_object() {
|
||||
}
|
||||
|
||||
valid_path() {
|
||||
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
|
||||
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* && -f "$1" && -r "$1" ]]
|
||||
}
|
||||
|
||||
resolve_wallpaper_path() {
|
||||
local candidate="$1"
|
||||
if valid_path "$candidate"; then
|
||||
resolved_wallpaper="$candidate"
|
||||
return
|
||||
fi
|
||||
if [[ -n "$candidate" ]]; then
|
||||
wallpaper_warning="One or more lock-screen wallpapers were unavailable; a safe fallback is in use."
|
||||
fi
|
||||
if valid_path "$shipped_wallpaper"; then
|
||||
resolved_wallpaper="$shipped_wallpaper"
|
||||
else
|
||||
resolved_wallpaper="screenshot"
|
||||
fi
|
||||
}
|
||||
|
||||
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
|
||||
@@ -134,10 +152,9 @@ load_preferences() {
|
||||
wallpaper_mode="$(read_string wallpaperMode single)"
|
||||
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|
||||
|| wallpaper_mode=single
|
||||
wallpaper_path="$(read_string wallpaperPath '')"
|
||||
if ! valid_path "$wallpaper_path"; then
|
||||
wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
|
||||
fi
|
||||
wallpaper_warning=""
|
||||
resolve_wallpaper_path "$(read_string wallpaperPath '')"
|
||||
wallpaper_path="$resolved_wallpaper"
|
||||
wallpaper_assignments="$(read_object wallpaperPerMonitor)"
|
||||
|
||||
case "$blur_level" in
|
||||
@@ -218,8 +235,9 @@ emit_backgrounds() {
|
||||
candidate="$(jq -r --arg output "$output" \
|
||||
'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
|
||||
<<<"$wallpaper_assignments" 2>/dev/null || true)"
|
||||
if valid_path "$candidate"; then
|
||||
assigned="$candidate"
|
||||
if [[ -n "$candidate" ]]; then
|
||||
resolve_wallpaper_path "$candidate"
|
||||
assigned="$resolved_wallpaper"
|
||||
fi
|
||||
fi
|
||||
emit_background "$output" "$assigned"
|
||||
@@ -344,7 +362,7 @@ generate() {
|
||||
write_status false "$fallback" true "The lock-screen configuration could not be generated."
|
||||
return 1
|
||||
fi
|
||||
write_status true "$generated" false ""
|
||||
write_status true "$generated" false "$wallpaper_warning"
|
||||
}
|
||||
|
||||
status() {
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Finds wallpaper candidates from fixed roots passed as separate arguments.
|
||||
# Keeping paths as argv values avoids shell interpolation for names containing
|
||||
# quotes, spaces, or other shell syntax.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
find "$@" -maxdepth 2 -type f \
|
||||
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \
|
||||
-printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -rn \
|
||||
| cut -d' ' -f2- \
|
||||
| awk 'NR <= 60'
|
||||
@@ -93,6 +93,14 @@ Singleton {
|
||||
function onRevisionChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
// Profile selection and custom edits both update preferences, but binding
|
||||
// directly to the effective profile also documents the in-shell ownership
|
||||
// boundary and keeps this propagation reactive if that storage changes.
|
||||
Connections {
|
||||
target: ThemeProfiles
|
||||
function onActiveProfileChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
|
||||
@@ -29,8 +29,24 @@ Singleton {
|
||||
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||
// modes: [{ label, mode, width, height, refresh }] }]
|
||||
property var monitors: []
|
||||
// Quickshell.screens is the topology authority. The override is only the
|
||||
// isolated harness model; normal sessions always observe Quickshell.
|
||||
property var screenOverride: null
|
||||
property string lastError: ""
|
||||
|
||||
readonly property var screenModel: Array.isArray(root.screenOverride)
|
||||
? root.screenOverride : Quickshell.screens
|
||||
readonly property string screenSignature: root.screenModel
|
||||
.map(screen => typeof screen === "string" ? screen : screen.name)
|
||||
.filter(name => !!name)
|
||||
.sort()
|
||||
.join("|")
|
||||
readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => {
|
||||
if (left.primary !== right.primary)
|
||||
return left.primary ? -1 : 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
})
|
||||
|
||||
// Set while a change is applied but not yet confirmed.
|
||||
property var pendingPreviousLayout: null
|
||||
property var pendingRequestedLayout: null
|
||||
@@ -113,6 +129,17 @@ Singleton {
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
onScreenSignatureChanged: root.reconcileTopology()
|
||||
|
||||
// A hotplug can invalidate the unconfirmed layout while the confirmation
|
||||
// is visible. Read the current compositor layout first; the normal queued
|
||||
// rollback then filters out any output that has disappeared.
|
||||
function reconcileTopology(): void {
|
||||
root.refresh();
|
||||
if (root.awaitingConfirmation)
|
||||
root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting.");
|
||||
}
|
||||
|
||||
function refresh(): bool {
|
||||
if (!query.running) {
|
||||
query.generation = root.operationGeneration;
|
||||
|
||||
@@ -136,7 +136,10 @@ Singleton {
|
||||
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
|
||||
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
|
||||
]
|
||||
|
||||
function pageFor(group: string): string {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
var MAX_NAME_LENGTH = 40;
|
||||
|
||||
var SHIPPED = [
|
||||
{
|
||||
id: "moon",
|
||||
name: "Moon",
|
||||
scheme: "dark",
|
||||
accent: "#82aaff",
|
||||
secondary: "#b172b0",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "moon-rose",
|
||||
name: "Moon Rose",
|
||||
scheme: "dark",
|
||||
accent: "#ff757f",
|
||||
secondary: "#c099ff",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "Day",
|
||||
scheme: "light",
|
||||
accent: "#2e7de9",
|
||||
secondary: "#9854f1",
|
||||
shipped: true
|
||||
}
|
||||
];
|
||||
|
||||
// The curated accents. `gnome` is the nearest member of GNOME's own
|
||||
// accent-color enum, which is a fixed list of nine we do not get to extend. It
|
||||
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
|
||||
// choosing an accent here recolors them too instead of leaving them in GNOME
|
||||
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
|
||||
// because it is the red role in this palette. adwaita-accent-contract reads
|
||||
// this table and fails when a member is missing or is not in GNOME's enum.
|
||||
var CURATED = {
|
||||
blue: {
|
||||
dark: "#82aaff", darkSecondary: "#b172b0",
|
||||
light: "#2e7de9", lightSecondary: "#9854f1",
|
||||
label: "Prism blue",
|
||||
gnome: "blue"
|
||||
},
|
||||
orchid: {
|
||||
dark: "#c099ff", darkSecondary: "#fca7ea",
|
||||
light: "#7847bd", lightSecondary: "#9854f1",
|
||||
label: "Orchid",
|
||||
gnome: "purple"
|
||||
},
|
||||
teal: {
|
||||
dark: "#86e1fc", darkSecondary: "#82aaff",
|
||||
light: "#007197", lightSecondary: "#2e7de9",
|
||||
label: "Teal",
|
||||
gnome: "teal"
|
||||
},
|
||||
green: {
|
||||
dark: "#c3e88d", darkSecondary: "#86e1fc",
|
||||
light: "#587539", lightSecondary: "#007197",
|
||||
label: "Green",
|
||||
gnome: "green"
|
||||
},
|
||||
amber: {
|
||||
dark: "#ffc777", darkSecondary: "#ff966c",
|
||||
light: "#8c6c3e", lightSecondary: "#b15c00",
|
||||
label: "Amber",
|
||||
gnome: "yellow"
|
||||
},
|
||||
orange: {
|
||||
dark: "#ff966c", darkSecondary: "#ff757f",
|
||||
light: "#b15c00", lightSecondary: "#c64343",
|
||||
label: "Orange",
|
||||
gnome: "orange"
|
||||
},
|
||||
rose: {
|
||||
dark: "#ff757f", darkSecondary: "#c099ff",
|
||||
light: "#f52a65", lightSecondary: "#9854f1",
|
||||
label: "Rose",
|
||||
gnome: "red"
|
||||
},
|
||||
slate: {
|
||||
dark: "#828bb8", darkSecondary: "#82aaff",
|
||||
light: "#6172b0", lightSecondary: "#2e7de9",
|
||||
label: "Slate",
|
||||
gnome: "slate"
|
||||
}
|
||||
};
|
||||
|
||||
function copyProfile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
scheme: profile.scheme,
|
||||
accent: profile.accent,
|
||||
secondary: profile.secondary,
|
||||
shipped: profile.shipped === true
|
||||
};
|
||||
}
|
||||
|
||||
function shippedProfiles() {
|
||||
return SHIPPED.map(copyProfile);
|
||||
}
|
||||
|
||||
function curatedAccents() {
|
||||
var result = {};
|
||||
Object.keys(CURATED).forEach(function(name) {
|
||||
result[name] = Object.assign({}, CURATED[name]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function isScheme(value) {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
function normalizedColor(value) {
|
||||
var color = String(value || "").trim().toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(color) ? color : null;
|
||||
}
|
||||
|
||||
function normalizeStoredProfile(value) {
|
||||
if (!value || typeof value !== "object" || value.shipped === true)
|
||||
return null;
|
||||
|
||||
var id = String(value.id || "").trim();
|
||||
var name = String(value.name || "").trim();
|
||||
var accent = normalizedColor(value.accent);
|
||||
var secondary = normalizedColor(value.secondary);
|
||||
if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
|
||||
|| name.length === 0 || name.length > MAX_NAME_LENGTH
|
||||
|| !isScheme(value.scheme) || !accent || !secondary)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
scheme: value.scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
}
|
||||
|
||||
function validCustomProfiles(values) {
|
||||
if (!Array.isArray(values))
|
||||
return [];
|
||||
|
||||
var ids = {};
|
||||
var names = {};
|
||||
SHIPPED.forEach(function(profile) {
|
||||
ids[profile.id] = true;
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
var result = [];
|
||||
values.forEach(function(value) {
|
||||
var profile = normalizeStoredProfile(value);
|
||||
if (!profile)
|
||||
return;
|
||||
var foldedName = profile.name.toLowerCase();
|
||||
if (ids[profile.id] || names[foldedName])
|
||||
return;
|
||||
ids[profile.id] = true;
|
||||
names[foldedName] = true;
|
||||
result.push(profile);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function profileCatalog(values) {
|
||||
return shippedProfiles().concat(validCustomProfiles(values));
|
||||
}
|
||||
|
||||
function boundedName(value) {
|
||||
var name = String(value || "").trim();
|
||||
if (!name)
|
||||
name = "Custom theme";
|
||||
return name.slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function uniqueName(value, profiles) {
|
||||
var requested = boundedName(value);
|
||||
var names = {};
|
||||
profiles.forEach(function(profile) {
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
if (!names[requested.toLowerCase()])
|
||||
return requested;
|
||||
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var ending = " " + suffix;
|
||||
var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
|
||||
if (!names[candidate.toLowerCase()])
|
||||
return candidate;
|
||||
}
|
||||
return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
|
||||
}
|
||||
|
||||
function slug(value) {
|
||||
var result = String(value || "").toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
return result || "theme";
|
||||
}
|
||||
|
||||
function uniqueId(name, profiles) {
|
||||
var base = "custom-" + slug(name);
|
||||
var ids = {};
|
||||
profiles.forEach(function(profile) { ids[profile.id] = true; });
|
||||
if (!ids[base])
|
||||
return base;
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
|
||||
if (!ids[candidate])
|
||||
return candidate;
|
||||
}
|
||||
return base.slice(0, 52) + "-10000";
|
||||
}
|
||||
|
||||
function createCustomProfile(values, input) {
|
||||
var customs = validCustomProfiles(values);
|
||||
var catalog = shippedProfiles().concat(customs);
|
||||
var scheme = input && input.scheme;
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var secondary = normalizedColor(input && input.secondary);
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var name = uniqueName(input && input.name, catalog);
|
||||
var profile = {
|
||||
id: uniqueId(name, catalog),
|
||||
name: name,
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
return { profiles: customs.concat([profile]), profile: profile };
|
||||
}
|
||||
|
||||
function findProfile(values, id) {
|
||||
var catalog = profileCatalog(values);
|
||||
for (var index = 0; index < catalog.length; index++) {
|
||||
if (catalog[index].id === id)
|
||||
return catalog[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function editProfile(values, selected, changes) {
|
||||
var customs = validCustomProfiles(values);
|
||||
if (!selected || typeof selected !== "object")
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var accent = normalizedColor(changes && changes.accent !== undefined
|
||||
? changes.accent : selected.accent);
|
||||
var secondary = normalizedColor(changes && changes.secondary !== undefined
|
||||
? changes.secondary : selected.secondary);
|
||||
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
if (selected.shipped === true) {
|
||||
return createCustomProfile(customs, {
|
||||
name: selected.name + " custom",
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
}
|
||||
|
||||
var stored = normalizeStoredProfile(selected);
|
||||
if (!stored)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var updated = Object.assign({}, stored, {
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
var found = false;
|
||||
var next = customs.map(function(profile) {
|
||||
if (profile.id !== updated.id)
|
||||
return profile;
|
||||
found = true;
|
||||
return updated;
|
||||
});
|
||||
if (!found)
|
||||
next.push(updated);
|
||||
return { profiles: next, profile: updated };
|
||||
}
|
||||
|
||||
function deleteProfile(values, id) {
|
||||
var customs = validCustomProfiles(values);
|
||||
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
|
||||
if (SHIPPED[shippedIndex].id === id)
|
||||
return { profiles: customs, removed: false };
|
||||
}
|
||||
var next = customs.filter(function(profile) { return profile.id !== id; });
|
||||
return { profiles: next, removed: next.length !== customs.length };
|
||||
}
|
||||
|
||||
function curatedPair(name, scheme) {
|
||||
var entry = CURATED[name] || CURATED.blue;
|
||||
if (scheme === "light")
|
||||
return { accent: entry.light, secondary: entry.lightSecondary };
|
||||
return { accent: entry.dark, secondary: entry.darkSecondary };
|
||||
}
|
||||
|
||||
function matchingShippedProfile(scheme, accent, secondary) {
|
||||
var first = normalizedColor(accent);
|
||||
var second = normalizedColor(secondary);
|
||||
for (var index = 0; index < SHIPPED.length; index++) {
|
||||
var profile = SHIPPED[index];
|
||||
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
||||
return copyProfile(profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function curatedNameForProfile(profile) {
|
||||
if (!profile || !isScheme(profile.scheme))
|
||||
return "";
|
||||
var accent = normalizedColor(profile.accent);
|
||||
var secondary = normalizedColor(profile.secondary);
|
||||
var names = Object.keys(CURATED);
|
||||
for (var index = 0; index < names.length; index++) {
|
||||
var name = names[index];
|
||||
var pair = curatedPair(name, profile.scheme);
|
||||
if (pair.accent === accent && pair.secondary === secondary)
|
||||
return name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, Number(value)));
|
||||
}
|
||||
|
||||
function channelHex(value) {
|
||||
var text = Math.round(value).toString(16);
|
||||
return text.length < 2 ? "0" + text : text;
|
||||
}
|
||||
|
||||
function hsvToHex(hue, saturation, value) {
|
||||
var h = Number(hue);
|
||||
var s = Number(saturation);
|
||||
var v = Number(value);
|
||||
if (!isFinite(h) || !isFinite(s) || !isFinite(v))
|
||||
return null;
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = clamp(s, 0, 100) / 100;
|
||||
v = clamp(v, 0, 100) / 100;
|
||||
|
||||
var chroma = v * s;
|
||||
var section = h / 60;
|
||||
var x = chroma * (1 - Math.abs(section % 2 - 1));
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
if (section < 1) { red = chroma; green = x; }
|
||||
else if (section < 2) { red = x; green = chroma; }
|
||||
else if (section < 3) { green = chroma; blue = x; }
|
||||
else if (section < 4) { green = x; blue = chroma; }
|
||||
else if (section < 5) { red = x; blue = chroma; }
|
||||
else { red = chroma; blue = x; }
|
||||
var match = v - chroma;
|
||||
return "#" + channelHex((red + match) * 255)
|
||||
+ channelHex((green + match) * 255)
|
||||
+ channelHex((blue + match) * 255);
|
||||
}
|
||||
|
||||
function hexToHsv(value) {
|
||||
var color = normalizedColor(value);
|
||||
if (!color)
|
||||
return null;
|
||||
var red = parseInt(color.slice(1, 3), 16) / 255;
|
||||
var green = parseInt(color.slice(3, 5), 16) / 255;
|
||||
var blue = parseInt(color.slice(5, 7), 16) / 255;
|
||||
var maximum = Math.max(red, green, blue);
|
||||
var minimum = Math.min(red, green, blue);
|
||||
var delta = maximum - minimum;
|
||||
var hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6);
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2);
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4);
|
||||
}
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
|
||||
v: Math.round(maximum * 100)
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
||||
shippedProfiles: shippedProfiles,
|
||||
curatedAccents: curatedAccents,
|
||||
validCustomProfiles: validCustomProfiles,
|
||||
profileCatalog: profileCatalog,
|
||||
createCustomProfile: createCustomProfile,
|
||||
findProfile: findProfile,
|
||||
editProfile: editProfile,
|
||||
deleteProfile: deleteProfile,
|
||||
curatedPair: curatedPair,
|
||||
matchingShippedProfile: matchingShippedProfile,
|
||||
curatedNameForProfile: curatedNameForProfile,
|
||||
hsvToHex: hsvToHex,
|
||||
hexToHsv: hexToHsv
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import "ThemeProfileModel.js" as ThemeProfileModel
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
|
||||
DesktopPreferences.get("themeProfiles"))
|
||||
readonly property var curatedAccents: ThemeProfileModel.curatedAccents()
|
||||
readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles)
|
||||
readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon"
|
||||
readonly property var activeProfile: ThemeProfileModel.findProfile(
|
||||
root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0]
|
||||
readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile(
|
||||
root.activeProfile)
|
||||
|
||||
property bool reconciling: false
|
||||
|
||||
function commitActive(profile: var): bool {
|
||||
if (!profile)
|
||||
return false;
|
||||
|
||||
// Scheme first: if another control changed it, the reconciliation hook
|
||||
// may briefly select Day/Moon before this final stable profile id lands.
|
||||
const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme);
|
||||
const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id);
|
||||
return schemeAccepted && profileAccepted;
|
||||
}
|
||||
|
||||
function selectProfile(id: string): bool {
|
||||
return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id));
|
||||
}
|
||||
|
||||
// Editing a shipped profile creates a saved custom copy. Editing a custom
|
||||
// profile updates only that record. ThemeProfileModel enforces both rules,
|
||||
// leaving this singleton responsible only for validated preference routing.
|
||||
function setAccentPair(accent: string, secondary: string): bool {
|
||||
const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function saveProfile(name: string): bool {
|
||||
const result = ThemeProfileModel.createCustomProfile(root.customProfiles, {
|
||||
name: name,
|
||||
scheme: root.activeProfile.scheme,
|
||||
accent: root.activeProfile.accent,
|
||||
secondary: root.activeProfile.secondary
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function deleteProfile(id: string): bool {
|
||||
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
|
||||
if (!result.removed)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
if (root.activeId === id)
|
||||
return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon");
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCuratedAccent(name: string): bool {
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
const pair = ThemeProfileModel.curatedPair(name, scheme);
|
||||
const shipped = ThemeProfileModel.matchingShippedProfile(
|
||||
scheme, pair.accent, pair.secondary);
|
||||
const accepted = shipped
|
||||
? root.commitActive(shipped)
|
||||
: root.setAccentPair(pair.accent, pair.secondary);
|
||||
if (accepted)
|
||||
SystemSettings.commitPreference("accentName", name);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function reconcileScheme(): void {
|
||||
if (root.reconciling)
|
||||
return;
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
if (root.activeProfile.scheme === scheme)
|
||||
return;
|
||||
root.reconciling = true;
|
||||
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
|
||||
root.reconciling = false;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.reconcileScheme()
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { root.reconcileScheme(); }
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,10 @@ Singleton {
|
||||
readonly property string outputSignature: root.outputNames().slice().sort().join("|")
|
||||
|
||||
property var outputNames: function() {
|
||||
if (Array.isArray(root.outputOverride))
|
||||
return root.outputOverride.slice();
|
||||
return Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||
const outputs = Array.isArray(root.outputOverride)
|
||||
? root.outputOverride.slice()
|
||||
: Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||
return root.primaryFirstOutputs(outputs);
|
||||
}
|
||||
|
||||
readonly property var searchRoots: [
|
||||
@@ -59,10 +60,8 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: ["bash", "-lc",
|
||||
"find " + root.searchRoots.map(dir => `'${dir}'`).join(" ")
|
||||
+ " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)"
|
||||
+ " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"]
|
||||
command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"]
|
||||
.concat(root.searchRoots)
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.available = this.text.split("\n")
|
||||
@@ -164,6 +163,18 @@ Singleton {
|
||||
activeQuery.running = true;
|
||||
}
|
||||
|
||||
// The saved primary role anchors Panama's per-monitor choices. Keep the
|
||||
// compositor's remaining order stable so a reconnect does not reshuffle
|
||||
// the rest of the picker unnecessarily.
|
||||
function primaryFirstOutputs(outputs: var): var {
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
const layouts = stored && typeof stored === "object" ? stored : {};
|
||||
const primary = outputs.find(output => layouts[output]?.primary === true);
|
||||
return primary === undefined
|
||||
? outputs
|
||||
: [primary].concat(outputs.filter(output => output !== primary));
|
||||
}
|
||||
|
||||
function candidates(extra: var): var {
|
||||
const result = [];
|
||||
const discovered = Array.isArray(root.candidateOverride)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "theme-profiles-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: ThemeProfiles.activeProfile,
|
||||
profiles: ThemeProfiles.profiles,
|
||||
stored: DesktopPreferences.get("themeProfiles")
|
||||
});
|
||||
}
|
||||
|
||||
function select(id: string): bool {
|
||||
return ThemeProfiles.selectProfile(id);
|
||||
}
|
||||
|
||||
function scheme(value: string): bool {
|
||||
return SystemSettings.commitPreference("colorScheme", value);
|
||||
}
|
||||
|
||||
function edit(accent: string, secondary: string): bool {
|
||||
return ThemeProfiles.setAccentPair(accent, secondary);
|
||||
}
|
||||
|
||||
function save(name: string): bool {
|
||||
return ThemeProfiles.saveProfile(name);
|
||||
}
|
||||
|
||||
function remove(id: string): bool {
|
||||
return ThemeProfiles.deleteProfile(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import qs.services
|
||||
|
||||
ShellRoot {
|
||||
Component.onCompleted: {
|
||||
Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"];
|
||||
Wallpaper.outputOverride = ["HDMI-A-1", "DP-2"];
|
||||
Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"];
|
||||
Wallpaper.startupRestoreEnabled = false;
|
||||
Wallpaper.slideshowIntervalOverrideMs = 60000;
|
||||
@@ -63,7 +63,9 @@ ShellRoot {
|
||||
assignments: DesktopPreferences.get("wallpaperPerMonitor"),
|
||||
slideshowPath: Wallpaper.slideshowPath,
|
||||
shuffleBag: Wallpaper.shuffleBag,
|
||||
slideshowTimerRunning: Wallpaper.slideshowTimerRunning
|
||||
slideshowTimerRunning: Wallpaper.slideshowTimerRunning,
|
||||
scanning: Wallpaper.scanning,
|
||||
available: Wallpaper.available
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ ShellRoot {
|
||||
return JSON.stringify(root.calls);
|
||||
}
|
||||
|
||||
function selectOutput(output: string): void {
|
||||
controls.selectedOutput = output;
|
||||
}
|
||||
|
||||
function updateOutputs(outputs: string): void {
|
||||
controls.outputs = outputs === "" ? [] : outputs.split("|");
|
||||
}
|
||||
|
||||
function selectedOutput(): string { return controls.selectedOutput; }
|
||||
|
||||
function states(): string {
|
||||
picker.mode = "single";
|
||||
const single = {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Panama Roadmap Completion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Close every audited correctness gap and finish the approved Phase 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 <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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+140
@@ -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'
|
||||
@@ -8,7 +8,8 @@
|
||||
# inside Panama's own surfaces, which look correct either way.
|
||||
#
|
||||
# Three things have to line up, and none of them share a source:
|
||||
# - config/Theme.qml carries the accent table with its `gnome` member
|
||||
# - services/ThemeProfileModel.js carries the accent table with its `gnome`
|
||||
# member (Theme.qml exposes it; the theme-profile system owns it)
|
||||
# - scripts/panama-theme-apps maps the same names in shell
|
||||
# - the portal routes Settings to a backend that can serve accent-color
|
||||
#
|
||||
@@ -17,7 +18,7 @@
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
|
||||
accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||
portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf"
|
||||
|
||||
@@ -26,25 +27,24 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$theme" "$script" "$portals"; do
|
||||
for path in "$accents" "$script" "$portals"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
|
||||
# ── The accent table ─────────────────────────────────────────────────────────
|
||||
mapping="$(python3 - "$theme" <<'PYTHON'
|
||||
mapping="$(python3 - "$accents" <<'PYTHON'
|
||||
import re, sys
|
||||
source = open(sys.argv[1]).read()
|
||||
table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S)
|
||||
table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S)
|
||||
if not table:
|
||||
raise SystemExit("accents table not found")
|
||||
for line in table.group(1).splitlines():
|
||||
name = re.search(r'"([a-z]+)":', line)
|
||||
if not name:
|
||||
continue
|
||||
member = re.search(r'gnome: "([a-z]+)"', line)
|
||||
print(name.group(1) + "\t" + (member.group(1) if member else ""))
|
||||
raise SystemExit("curated accent table not found")
|
||||
# Each accent is a multi-line record now, so the name opens a block and the
|
||||
# member may be several lines below it.
|
||||
for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S):
|
||||
member = re.search(r'gnome: "([a-z]+)"', entry.group(2))
|
||||
print(entry.group(1) + "\t" + (member.group(1) if member else ""))
|
||||
PYTHON
|
||||
)" || fail 'could not read the accent table from Theme.qml'
|
||||
)" || fail 'could not read the curated accent table from ThemeProfileModel.js'
|
||||
|
||||
[[ -n "$mapping" ]] || fail 'the accent table is empty'
|
||||
|
||||
|
||||
@@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p
|
||||
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|
||||
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
|
||||
|
||||
# Controls added after the original contract remain service-backed rather than
|
||||
# becoming optimistic local toggles. Keep these checks in the static section so
|
||||
# they can run without mapping the Control Center on a daily-driver desktop.
|
||||
rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile control does not follow daemon availability'
|
||||
rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'opening Control Center does not refresh the external power profile'
|
||||
rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile choices have no detail section'
|
||||
rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile choices are not mounted'
|
||||
|
||||
rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light control does not reflect service state'
|
||||
rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light primary action is not wired'
|
||||
rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light schedule action is not wired'
|
||||
|
||||
rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'colour scheme control does not expose its current state'
|
||||
rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'colour scheme control bypasses the preference commit path'
|
||||
|
||||
if [[ "${1:-}" == "--static-only" ]]; then
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Control Center contract (static): PASS\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
: >"$helper_log"
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
|
||||
@@ -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" ]] \
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Executable
+43
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
|
||||
node - "$model" <<'JS'
|
||||
const assert = require('node:assert/strict')
|
||||
const model = require(process.argv[2])
|
||||
|
||||
const shipped = model.shippedProfiles()
|
||||
assert.deepEqual(shipped, [
|
||||
{
|
||||
id: 'moon',
|
||||
name: 'Moon',
|
||||
scheme: 'dark',
|
||||
accent: '#82aaff',
|
||||
secondary: '#b172b0',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'moon-rose',
|
||||
name: 'Moon Rose',
|
||||
scheme: 'dark',
|
||||
accent: '#ff757f',
|
||||
secondary: '#c099ff',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'day',
|
||||
name: 'Day',
|
||||
scheme: 'light',
|
||||
accent: '#2e7de9',
|
||||
secondary: '#9854f1',
|
||||
shipped: true
|
||||
}
|
||||
])
|
||||
|
||||
const first = model.createCustomProfile([], {
|
||||
name: ' Ocean ',
|
||||
scheme: 'dark',
|
||||
accent: '#86E1FC',
|
||||
secondary: '#82AAFF'
|
||||
})
|
||||
assert.deepEqual(first.profile, {
|
||||
id: 'custom-ocean',
|
||||
name: 'Ocean',
|
||||
scheme: 'dark',
|
||||
accent: '#86e1fc',
|
||||
secondary: '#82aaff',
|
||||
shipped: false
|
||||
})
|
||||
|
||||
const second = model.createCustomProfile(first.profiles, {
|
||||
name: 'ocean',
|
||||
scheme: 'light',
|
||||
accent: '#007197',
|
||||
secondary: '#2e7de9'
|
||||
})
|
||||
assert.equal(second.profile.name, 'ocean 2')
|
||||
assert.equal(second.profile.id, 'custom-ocean-2')
|
||||
|
||||
const bounded = model.createCustomProfile(second.profiles, {
|
||||
name: 'A theme name that is deliberately much longer than forty characters',
|
||||
scheme: 'dark',
|
||||
accent: '#c3e88d',
|
||||
secondary: '#86e1fc'
|
||||
})
|
||||
assert.equal(bounded.profile.name.length, 40)
|
||||
|
||||
const originalMoon = shipped[0]
|
||||
const edited = model.editProfile([], originalMoon, {
|
||||
accent: '#ffc777',
|
||||
secondary: '#ff966c'
|
||||
})
|
||||
assert.equal(edited.profile.shipped, false)
|
||||
assert.equal(edited.profile.name, 'Moon custom')
|
||||
assert.equal(edited.profiles.length, 1)
|
||||
assert.deepEqual(originalMoon, shipped[0])
|
||||
assert.equal(shipped[0].accent, '#82aaff')
|
||||
|
||||
const refusedDelete = model.deleteProfile(edited.profiles, 'moon')
|
||||
assert.equal(refusedDelete.removed, false)
|
||||
assert.deepEqual(refusedDelete.profiles, edited.profiles)
|
||||
|
||||
assert.deepEqual(model.profileCatalog([
|
||||
edited.profile,
|
||||
{ id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false },
|
||||
{ id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false }
|
||||
]), [...shipped, edited.profile])
|
||||
|
||||
assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
|
||||
assert.equal(model.hsvToHex(240, 100, 100), '#0000ff')
|
||||
assert.equal(model.hsvToHex(360, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(0, 0, 50), '#808080')
|
||||
assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 })
|
||||
assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 })
|
||||
assert.equal(model.hexToHsv('not-a-colour'), null)
|
||||
assert.equal(model.curatedNameForProfile(shipped[0]), 'blue')
|
||||
assert.equal(model.curatedNameForProfile(shipped[1]), 'rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'dark', '#ff757f', '#c099ff'
|
||||
).id, 'moon-rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'light', '#2e7de9', '#9854f1'
|
||||
).id, 'day')
|
||||
assert.equal(model.curatedNameForProfile({
|
||||
id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
|
||||
accent: '#123456', secondary: '#654321', shipped: false
|
||||
}), '')
|
||||
|
||||
console.log('theme profiles contract: PASS')
|
||||
JS
|
||||
|
||||
harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)"
|
||||
|
||||
qs_for_test() {
|
||||
XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
|
||||
QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
qs_for_test --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \
|
||||
|| { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; }
|
||||
|
||||
status="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select day >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null
|
||||
edited="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \
|
||||
<<<"$edited" >/dev/null
|
||||
custom_id="$(jq -r '.active.id' <<<"$edited")"
|
||||
|
||||
qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null
|
||||
saved="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null
|
||||
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]]
|
||||
forest_id="$(jq -r '.active.id' <<<"$saved")"
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]]
|
||||
jq -e '.active.id == "day" and .active.shipped == true' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'theme profiles service contract: PASS\n'
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml"
|
||||
scanner="$repo_dir/config/dot/quickshell/scripts/panama-wallpaper-scan"
|
||||
fixture="$(mktemp -d /tmp/panama-wallpaper-service.XXXXXX)"
|
||||
config_home="$fixture/config"
|
||||
state_home="$fixture/state"
|
||||
@@ -15,6 +16,10 @@ active_state="$fixture/active.json"
|
||||
control="$fixture/control"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
quoted_home="$fixture/home's"
|
||||
quoted_wallpaper="$quoted_home/Pictures/Wallpapers/quoted.jpg"
|
||||
bulk_wallpapers="$fixture/bulk-wallpapers"
|
||||
bulk_segment="wallpaper-root-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
fail() {
|
||||
printf 'wallpaper service contract: %s\n' "$1" >&2
|
||||
@@ -46,9 +51,34 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$config_home/panama" "$state_home" "$test_bin"
|
||||
for _ in $(seq 1 15); do
|
||||
bulk_wallpapers="$bulk_wallpapers/$bulk_segment"
|
||||
done
|
||||
mkdir -p "$config_home/panama" "$state_home" "$test_bin" \
|
||||
"$quoted_home/Pictures/Wallpapers" "$bulk_wallpapers"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
printf '%s\n' '{}' >"$config_home/panama/settings.json"
|
||||
printf 'quoted wallpaper\n' >"$quoted_wallpaper"
|
||||
for index in $(seq 1 401); do
|
||||
wallpaper="$bulk_wallpapers/wallpaper-$index.jpg"
|
||||
printf 'bulk wallpaper %s\n' "$index" >"$wallpaper"
|
||||
touch -d "@$((1700000000 + index))" "$wallpaper"
|
||||
done
|
||||
if ! bulk_scan="$("$scanner" "$bulk_wallpapers")"; then
|
||||
fail 'a normal scan with more than 60 images exited non-zero'
|
||||
fi
|
||||
expected_bulk_scan="$(for index in $(seq 401 -1 342); do
|
||||
printf '%s\n' "$bulk_wallpapers/wallpaper-$index.jpg"
|
||||
done)"
|
||||
[[ "$bulk_scan" == "$expected_bulk_scan" ]] \
|
||||
|| fail "over-cap scan did not emit exactly the newest 60 images: $bulk_scan"
|
||||
cat >"$config_home/panama/settings.json" <<'JSON'
|
||||
{
|
||||
"displays": {
|
||||
"DP-2": {"mode":"[email protected]","scale":1.5,"transform":0,"x":0,"y":0,"primary":true},
|
||||
"HDMI-A-1": {"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":0,"primary":false}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
printf '%s\n' '{}' >"$active_state"
|
||||
: >"$command_log"
|
||||
: >"$control"
|
||||
@@ -92,6 +122,7 @@ qs_for_harness() {
|
||||
XDG_CONFIG_HOME="$config_home"
|
||||
XDG_STATE_HOME="$state_home"
|
||||
PATH="$test_bin:$PATH"
|
||||
HOME="$quoted_home"
|
||||
PANAMA_WALLPAPER_COMMAND_LOG="$command_log"
|
||||
PANAMA_WALLPAPER_ACTIVE_STATE="$active_state"
|
||||
PANAMA_WALLPAPER_CONTROL="$control"
|
||||
@@ -113,6 +144,18 @@ wait_idle() {
|
||||
fail "wallpaper transaction did not settle: $status"
|
||||
}
|
||||
|
||||
wait_for_scan() {
|
||||
local status=""
|
||||
for _ in $(seq 1 80); do
|
||||
status="$(qs_for_harness ipc call wallpaper-service-test status)"
|
||||
jq -e --arg path "$quoted_wallpaper" \
|
||||
'.scanning == false and (.available | index($path) != null)' \
|
||||
<<<"$status" >/dev/null && return
|
||||
sleep 0.1
|
||||
done
|
||||
fail "argument-safe wallpaper scan did not discover the quoted HOME image: $status"
|
||||
}
|
||||
|
||||
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated service harness did not launch'
|
||||
for _ in $(seq 1 60); do
|
||||
harness_pid="$(instances_for_harness | head -1)"
|
||||
@@ -123,6 +166,7 @@ for _ in $(seq 1 60); do
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated service harness process did not start'
|
||||
wait_for_scan
|
||||
wait_idle >/dev/null
|
||||
|
||||
: >"$command_log"
|
||||
|
||||
@@ -68,6 +68,15 @@ done
|
||||
[[ "$(qs_for_harness ipc call wallpaper-settings-test activate per-monitor /images/c.jpg)" \
|
||||
== '["assign:DP-2:/images/c.jpg"]' ]] || fail 'Per-display mode did not assign the selected output'
|
||||
|
||||
qs_for_harness ipc call wallpaper-settings-test selectOutput HDMI-A-1 >/dev/null
|
||||
qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2|HDMI-A-1' >/dev/null
|
||||
[[ "$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" == 'HDMI-A-1' ]] \
|
||||
|| fail 'a valid per-monitor selection was reset during topology reconciliation'
|
||||
qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2' >/dev/null
|
||||
selected_after_disconnect="$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)"
|
||||
[[ "$selected_after_disconnect" == 'DP-2' ]] \
|
||||
|| fail "a disconnected per-monitor selection did not fall back to the primary output: $selected_after_disconnect"
|
||||
|
||||
states="$(qs_for_harness ipc call wallpaper-settings-test states)"
|
||||
jq -e '.selectedOutput == "DP-2"
|
||||
and .single == {"current":true,"selected":true}
|
||||
|
||||
Reference in New Issue
Block a user