Add wallpaper mode controls

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 1c12892252
commit c36e423890
9 changed files with 388 additions and 6 deletions
@@ -50,9 +50,16 @@ SettingsPage {
? Wallpaper.lastError ? Wallpaper.lastError
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." : "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperControls {
id: wallpaperControls
width: parent.width
}
WallpaperPicker { WallpaperPicker {
id: wallpapers id: wallpapers
width: parent.width width: parent.width
mode: wallpaperControls.mode
selectedOutput: wallpaperControls.selectedOutput
} }
ActionRow { ActionRow {
@@ -0,0 +1,103 @@
import QtQuick
import qs.config
import qs.services
import qs.widgets
Column {
id: root
property string mode: DesktopPreferences.get("wallpaperMode")
property var outputs: Wallpaper.outputNames()
property string selectedOutput: root.outputs.length > 0 ? root.outputs[0] : ""
property var setModeAction: function(mode) { Wallpaper.setMode(mode); }
property var setIntervalAction: function(minutes) { Wallpaper.setIntervalMinutes(minutes); }
property var setShuffleAction: function(enabled) { Wallpaper.setShuffle(enabled); }
width: parent ? parent.width : 620
spacing: 4
ChoiceGrid {
width: parent.width
label: "Wallpaper mode"
detail: "Use one image, rotate a collection, or choose per display"
options: PreferenceSchema.spec("wallpaperMode").options
current: root.mode
onPicked: value => root.setModeAction(value)
}
ChoiceGrid {
visible: root.mode === "per-monitor"
width: parent.width
label: "Display"
detail: "Choose which display the thumbnail grid assigns"
options: root.outputs.map(output => ({ value: output, label: output }))
current: root.selectedOutput
onPicked: value => root.selectedOutput = value
}
SettingRow {
visible: root.mode === "slideshow"
label: "Change background every"
detail: "Time between slideshow images"
controlWidth: 280
Item {
anchors.fill: parent
ValueSlider {
anchors.left: parent.left
anchors.right: intervalText.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
value: (DesktopPreferences.get("wallpaperIntervalMinutes") - 5) / 1435
onMoved: ratio => {
const raw = 5 + ratio * 1435;
root.setIntervalAction(Math.max(5, Math.min(1440, Math.round(raw / 5) * 5)));
}
}
Text {
id: intervalText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 62
text: DesktopPreferences.get("wallpaperIntervalMinutes") + " min"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
}
}
SettingRow {
visible: root.mode === "slideshow"
label: "Shuffle"
detail: "Show every selected image before repeating"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: DesktopPreferences.get("wallpaperShuffle") === true
onToggled: enabled => root.setShuffleAction(enabled)
}
}
Text {
width: parent.width
visible: root.mode === "slideshow"
&& (DesktopPreferences.get("wallpaperSlideshowPaths") ?? []).length === 0
text: "Select two or more images below to begin a slideshow."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
topPadding: 5
bottomPadding: 8
}
}
@@ -24,6 +24,14 @@ Item {
// unreachable without a long scroll past pictures. Two rows by default, // unreachable without a long scroll past pictures. Two rows by default,
// all of them on request. // all of them on request.
property bool expanded: false property bool expanded: false
property string mode: DesktopPreferences.get("wallpaperMode")
property string selectedOutput: Wallpaper.outputNames()[0] ?? ""
property var activeByOutput: Wallpaper.activeByOutput
property var slideshowPaths: DesktopPreferences.get("wallpaperSlideshowPaths") ?? []
property var assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({})
property var setSingleAction: function(path) { Wallpaper.setSingle(path); }
property var toggleSlideshowAction: function(path) { Wallpaper.toggleSlideshowPath(path); }
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2 readonly property int collapsedRows: 2
readonly property var shown: root.expanded readonly property var shown: root.expanded
@@ -35,6 +43,27 @@ Item {
readonly property int columns: Math.max(2, Math.floor(width / 190)) readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160 readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
return root.activeByOutput[root.selectedOutput] === path;
}
function selected(path: string): bool {
if (root.mode === "slideshow")
return root.slideshowPaths.includes(path);
if (root.mode === "per-monitor")
return root.assignments[root.selectedOutput] === path;
return root.isCurrent(path);
}
function activate(path: string): void {
if (root.mode === "slideshow")
root.toggleSlideshowAction(path);
else if (root.mode === "per-monitor")
root.setAssignmentAction(root.selectedOutput, path);
else
root.setSingleAction(path);
}
Grid { Grid {
id: grid id: grid
@@ -50,7 +79,8 @@ Item {
required property var modelData required property var modelData
readonly property bool current: Wallpaper.active === tile.modelData readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth width: root.cellWidth
height: Math.round(root.cellWidth * 9 / 16) height: Math.round(root.cellWidth * 9 / 16)
@@ -114,6 +144,28 @@ Item {
border.color: Theme.accent border.color: Theme.accent
} }
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 24
height: 24
radius: 12
visible: tile.member
color: Theme.alpha(Theme.bgDark, 0.82)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.16)
Text {
anchors.centerIn: parent
text: "✓"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 14
font.weight: Font.DemiBold
}
}
Rectangle { Rectangle {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
@@ -132,7 +184,9 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: 7 anchors.margins: 7
text: tile.current ? "Current wallpaper" : Wallpaper.titleFor(tile.modelData) text: tile.current
? "Current wallpaper"
: (tile.member ? "In slideshow" : Wallpaper.titleFor(tile.modelData))
color: tile.current ? Theme.accent : Theme.fg color: tile.current ? Theme.accent : Theme.fg
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -144,7 +198,7 @@ Item {
HoverHandler { id: hover } HoverHandler { id: hover }
TapHandler { TapHandler {
onTapped: Wallpaper.set(tile.modelData) onTapped: root.activate(tile.modelData)
} }
} }
} }
@@ -34,6 +34,7 @@ PowerPage 1.0 PowerPage.qml
DateTimePage 1.0 DateTimePage.qml DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml DockPinsEditor 1.0 DockPinsEditor.qml
@@ -68,7 +68,8 @@ Singleton {
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" }, { label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" }, { label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" }, { label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" } { label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }
] ]
function pageFor(group: string): string { function pageFor(group: string): string {
@@ -96,7 +97,8 @@ Singleton {
for (const entry of PreferenceSchema.entries) { for (const entry of PreferenceSchema.entries) {
if (entry.internal) if (entry.internal)
continue; continue;
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group}`.toLowerCase(); const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
if (haystack.indexOf(needle) >= 0) if (haystack.indexOf(needle) >= 0)
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting"); add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
} }
@@ -113,8 +115,16 @@ Singleton {
// Exact prefix matches first: typing "blur" should put "Blur" above // Exact prefix matches first: typing "blur" should put "Blur" above
// "Blur radius", and both above a setting that merely mentions blur in // "Blur radius", and both above a setting that merely mentions blur in
// its explanation. // its explanation. An exact enum option also leads: "slideshow" is a
// mode choice, so Wallpaper mode belongs above the interval row that
// merely explains it.
return results.sort((a, b) => { return results.sort((a, b) => {
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
if (ao !== bo)
return ao ? -1 : 1;
const al = a.label.toLowerCase(); const al = a.label.toLowerCase();
const bl = b.label.toLowerCase(); const bl = b.label.toLowerCase();
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2); const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
@@ -368,6 +368,55 @@ Singleton {
return root.applyPolicy(policy, true, false); return root.applyPolicy(policy, true, false);
} }
function setMode(mode: string): bool {
if (!["single", "slideshow", "per-monitor"].includes(mode))
return false;
const policy = root.currentPolicy();
policy.mode = mode;
if (mode === "slideshow") {
const collection = WallpaperPolicy.validCollection(
policy.collection, root.candidates([]));
if (!collection.includes(policy.slideshowPath))
policy.slideshowPath = collection[0] || policy.globalPath;
}
return root.applyPolicy(policy, true, false);
}
function setAssignment(output: string, path: string): bool {
if (!root.outputNames().includes(output)
|| !WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
policy.mode = "per-monitor";
policy.assignments = Object.assign({}, policy.assignments);
policy.assignments[output] = path;
return root.applyPolicy(policy, true, false);
}
function toggleSlideshowPath(path: string): bool {
if (!WallpaperPolicy.validPath(path, root.candidates([])))
return false;
const policy = root.currentPolicy();
const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([]));
policy.collection = collection.includes(path)
? collection.filter(candidate => candidate !== path)
: collection.concat([path]);
policy.mode = "slideshow";
if (!policy.collection.includes(policy.slideshowPath))
policy.slideshowPath = policy.collection[0] || policy.globalPath;
return root.applyPolicy(policy, true, false);
}
function setIntervalMinutes(minutes: int): bool {
const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes);
return value !== undefined
&& DesktopPreferences.set("wallpaperIntervalMinutes", value);
}
function setShuffle(enabled: bool): bool {
return DesktopPreferences.set("wallpaperShuffle", enabled === true);
}
// Compatibility boundary used by backup/reset and the existing picker. // Compatibility boundary used by backup/reset and the existing picker.
function set(path: string): bool { function set(path: string): bool {
return root.setSingle(path); return root.setSingle(path);
@@ -0,0 +1,73 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
ShellRoot {
id: root
property var calls: []
function record(call: string): void {
root.calls = root.calls.concat([call]);
}
WallpaperControls {
id: controls
width: 620
outputs: ["DP-2", "HDMI-A-1"]
mode: "per-monitor"
setModeAction: mode => root.record("mode:" + mode)
setIntervalAction: minutes => root.record("interval:" + minutes)
setShuffleAction: enabled => root.record("shuffle:" + enabled)
}
WallpaperPicker {
id: picker
width: 620
selectedOutput: "DP-2"
mode: "single"
activeByOutput: ({ "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" })
slideshowPaths: ["/images/b.jpg", "/images/c.jpg"]
assignments: ({ "DP-2": "/images/c.jpg" })
setSingleAction: path => root.record("single:" + path)
toggleSlideshowAction: path => root.record("toggle:" + path)
setAssignmentAction: (output, path) => root.record("assign:" + output + ":" + path)
}
IpcHandler {
target: "wallpaper-settings-test"
function activate(mode: string, path: string): string {
root.calls = [];
picker.mode = mode;
picker.activate(path);
return JSON.stringify(root.calls);
}
function states(): string {
picker.mode = "single";
const single = {
current: picker.isCurrent("/images/a.jpg"),
selected: picker.selected("/images/a.jpg")
};
picker.mode = "slideshow";
const slideshow = {
current: picker.isCurrent("/images/b.jpg"),
selected: picker.selected("/images/b.jpg")
};
picker.mode = "per-monitor";
const perMonitor = {
current: picker.isCurrent("/images/c.jpg"),
selected: picker.selected("/images/c.jpg")
};
return JSON.stringify({
selectedOutput: controls.selectedOutput,
single,
slideshow,
perMonitor
});
}
}
}
@@ -62,6 +62,9 @@ system health|System Health|services
doctor|Copy health report|services doctor|Copy health report|services
lock screen background|Lock screen background|appearance lock screen background|Lock screen background|appearance
password field|Password field|appearance password field|Password field|appearance
slideshow|Wallpaper mode|appearance
shuffle|Shuffle|appearance
per-display wallpaper|Per-display wallpaper|appearance
CASES CASES
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \ ! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
controls="$repo_dir/config/dot/quickshell/modules/settings/WallpaperControls.qml"
picker="$repo_dir/config/dot/quickshell/modules/settings/WallpaperPicker.qml"
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
harness="$repo_dir/config/dot/quickshell/wallpaper-settings-harness.qml"
state_home="$(mktemp -d /tmp/panama-wallpaper-settings-state.XXXXXX)"
shell_log="$state_home/quickshell.log"
harness_pid=""
fail() {
printf 'wallpaper settings contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
exit 1
}
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
fi
rm -rf "$state_home"
}
trap cleanup EXIT
[[ -f "$controls" ]] || fail 'WallpaperControls.qml is missing'
rg -Fq 'WallpaperControls {' "$appearance" || fail 'Appearance does not show wallpaper mode controls'
rg -Fq 'selectedOutput: wallpaperControls.selectedOutput' "$appearance" \
|| fail 'thumbnail actions do not follow the selected display'
qs_for_harness() {
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
else
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
fi
}
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated settings harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target wallpaper-settings-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated settings harness process did not start'
[[ "$(qs_for_harness ipc call wallpaper-settings-test activate single /images/a.jpg)" \
== '["single:/images/a.jpg"]' ]] || fail 'Single mode did not call setSingle'
[[ "$(qs_for_harness ipc call wallpaper-settings-test activate slideshow /images/b.jpg)" \
== '["toggle:/images/b.jpg"]' ]] || fail 'Slideshow mode did not toggle collection membership'
[[ "$(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'
states="$(qs_for_harness ipc call wallpaper-settings-test states)"
jq -e '.selectedOutput == "DP-2"
and .single == {"current":true,"selected":true}
and .slideshow == {"current":false,"selected":true}
and .perMonitor == {"current":false,"selected":true}' \
<<<"$states" >/dev/null || fail "active Prism and selection states were conflated: $states"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
fail 'settings harness emitted a QML runtime warning'
fi
printf 'wallpaper settings contract: PASS\n'