diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 780580f..9095478 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -6,6 +6,10 @@ import qs.config import qs.services ShellRoot { + // The isolated screen model begins with both fixture outputs so changing + // it below exercises the same reactive topology path as a real hotplug. + Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"] + IpcHandler { target: "displays-test" @@ -42,6 +46,7 @@ ShellRoot { function transactionStatus(): string { return JSON.stringify({ layout: Displays.currentLayout(), + primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name), pending: Displays.pendingRequestedLayout, previous: Displays.pendingPreviousLayout, reverting: Displays.revertExpectedLayout, @@ -141,5 +146,8 @@ ShellRoot { if (monitor) Displays.forget(monitor.name); } function refresh(): void { Displays.refresh(); } + function setScreenModel(names: string): void { + Displays.screenOverride = JSON.parse(names); + } } } diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 0f9987e..7164491 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -22,7 +22,7 @@ SettingsPage { property string selectedOutput: "" readonly property var monitor: Displays.monitorNamed(root.selectedOutput) - ?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null) + ?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null) readonly property string currentMode: root.monitor ? root.monitor.mode : "" @@ -38,7 +38,8 @@ SettingsPage { function syncSelectedOutput(): void { if (!Displays.monitorNamed(root.selectedOutput)) - root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; + root.selectedOutput = Displays.primaryFirstMonitors.length > 0 + ? Displays.primaryFirstMonitors[0].name : ""; } // Probing I2C for DDC-capable monitors takes on the order of a second, so @@ -137,7 +138,7 @@ SettingsPage { ChoiceGrid { width: parent.width label: "Display" - options: Displays.monitors.map(monitor => ({ + options: Displays.primaryFirstMonitors.map(monitor => ({ value: monitor.name, label: monitor.description || monitor.name })) diff --git a/config/dot/quickshell/modules/settings/WallpaperControls.qml b/config/dot/quickshell/modules/settings/WallpaperControls.qml index a3eaf10..e33aa65 100644 --- a/config/dot/quickshell/modules/settings/WallpaperControls.qml +++ b/config/dot/quickshell/modules/settings/WallpaperControls.qml @@ -18,6 +18,13 @@ Column { width: parent ? parent.width : 620 spacing: 4 + onOutputsChanged: root.syncSelectedOutput() + + function syncSelectedOutput(): void { + if (!root.outputs.includes(root.selectedOutput)) + root.selectedOutput = root.outputs.length > 0 ? root.outputs[0] : ""; + } + ChoiceGrid { width: parent.width label: "Wallpaper mode" diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 4d4a837..ed62d1a 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -17,8 +17,10 @@ status_file="$state_dir/hyprlock-status.json" fallback="$config_home/hypr/hyprlock.conf" temporary="$generated.tmp.$$" status_temporary="$status_file.tmp.$$" +shipped_wallpaper="$HOME/Pictures/Wallpapers/faroe_islands.jpg" settings_valid=false +wallpaper_warning="" cleanup() { rm -f "$temporary" "$status_temporary" 2>/dev/null || true @@ -76,7 +78,23 @@ read_object() { } valid_path() { - [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]] + [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* && -f "$1" && -r "$1" ]] +} + +resolve_wallpaper_path() { + local candidate="$1" + if valid_path "$candidate"; then + resolved_wallpaper="$candidate" + return + fi + if [[ -n "$candidate" ]]; then + wallpaper_warning="One or more lock-screen wallpapers were unavailable; a safe fallback is in use." + fi + if valid_path "$shipped_wallpaper"; then + resolved_wallpaper="$shipped_wallpaper" + else + resolved_wallpaper="screenshot" + fi } # Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by @@ -134,10 +152,9 @@ load_preferences() { wallpaper_mode="$(read_string wallpaperMode single)" [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ || wallpaper_mode=single - wallpaper_path="$(read_string wallpaperPath '')" - if ! valid_path "$wallpaper_path"; then - wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg" - fi + wallpaper_warning="" + resolve_wallpaper_path "$(read_string wallpaperPath '')" + wallpaper_path="$resolved_wallpaper" wallpaper_assignments="$(read_object wallpaperPerMonitor)" case "$blur_level" in @@ -218,8 +235,9 @@ emit_backgrounds() { candidate="$(jq -r --arg output "$output" \ 'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \ <<<"$wallpaper_assignments" 2>/dev/null || true)" - if valid_path "$candidate"; then - assigned="$candidate" + if [[ -n "$candidate" ]]; then + resolve_wallpaper_path "$candidate" + assigned="$resolved_wallpaper" fi fi emit_background "$output" "$assigned" @@ -344,7 +362,7 @@ generate() { write_status false "$fallback" true "The lock-screen configuration could not be generated." return 1 fi - write_status true "$generated" false "" + write_status true "$generated" false "$wallpaper_warning" } status() { diff --git a/config/dot/quickshell/scripts/panama-wallpaper-scan b/config/dot/quickshell/scripts/panama-wallpaper-scan new file mode 100755 index 0000000..9097287 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wallpaper-scan @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Finds wallpaper candidates from fixed roots passed as separate arguments. +# Keeping paths as argv values avoids shell interpolation for names containing +# quotes, spaces, or other shell syntax. + +set -euo pipefail + +find "$@" -maxdepth 2 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \ + -printf '%T@ %p\n' 2>/dev/null \ + | sort -rn \ + | cut -d' ' -f2- \ + | head -60 diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index a094af1..ef2fd2f 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -29,8 +29,24 @@ Singleton { // [{ name, description, width, height, refreshRate, scale, transform, // modes: [{ label, mode, width, height, refresh }] }] property var monitors: [] + // Quickshell.screens is the topology authority. The override is only the + // isolated harness model; normal sessions always observe Quickshell. + property var screenOverride: null property string lastError: "" + readonly property var screenModel: Array.isArray(root.screenOverride) + ? root.screenOverride : Quickshell.screens + readonly property string screenSignature: root.screenModel + .map(screen => typeof screen === "string" ? screen : screen.name) + .filter(name => !!name) + .sort() + .join("|") + readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => { + if (left.primary !== right.primary) + return left.primary ? -1 : 1; + return left.name.localeCompare(right.name); + }) + // Set while a change is applied but not yet confirmed. property var pendingPreviousLayout: null property var pendingRequestedLayout: null @@ -113,6 +129,17 @@ Singleton { Component.onCompleted: root.refresh() + onScreenSignatureChanged: root.reconcileTopology() + + // A hotplug can invalidate the unconfirmed layout while the confirmation + // is visible. Read the current compositor layout first; the normal queued + // rollback then filters out any output that has disappeared. + function reconcileTopology(): void { + root.refresh(); + if (root.awaitingConfirmation) + root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting."); + } + function refresh(): bool { if (!query.running) { query.generation = root.operationGeneration; diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index e68db62..ec4f15e 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -45,9 +45,10 @@ Singleton { readonly property string outputSignature: root.outputNames().slice().sort().join("|") property var outputNames: function() { - if (Array.isArray(root.outputOverride)) - return root.outputOverride.slice(); - return Quickshell.screens.map(screen => screen.name).filter(name => !!name); + const outputs = Array.isArray(root.outputOverride) + ? root.outputOverride.slice() + : Quickshell.screens.map(screen => screen.name).filter(name => !!name); + return root.primaryFirstOutputs(outputs); } readonly property var searchRoots: [ @@ -59,10 +60,8 @@ Singleton { Process { id: scan - command: ["bash", "-lc", - "find " + root.searchRoots.map(dir => `'${dir}'`).join(" ") - + " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)" - + " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"] + command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"] + .concat(root.searchRoots) stdout: StdioCollector { onStreamFinished: { root.available = this.text.split("\n") @@ -164,6 +163,18 @@ Singleton { activeQuery.running = true; } + // The saved primary role anchors Panama's per-monitor choices. Keep the + // compositor's remaining order stable so a reconnect does not reshuffle + // the rest of the picker unnecessarily. + function primaryFirstOutputs(outputs: var): var { + const stored = DesktopPreferences.get("displays"); + const layouts = stored && typeof stored === "object" ? stored : {}; + const primary = outputs.find(output => layouts[output]?.primary === true); + return primary === undefined + ? outputs + : [primary].concat(outputs.filter(output => output !== primary)); + } + function candidates(extra: var): var { const result = []; const discovered = Array.isArray(root.candidateOverride) diff --git a/config/dot/quickshell/wallpaper-service-harness.qml b/config/dot/quickshell/wallpaper-service-harness.qml index 57ef178..6fe92a8 100644 --- a/config/dot/quickshell/wallpaper-service-harness.qml +++ b/config/dot/quickshell/wallpaper-service-harness.qml @@ -7,7 +7,7 @@ import qs.services ShellRoot { Component.onCompleted: { - Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"]; + Wallpaper.outputOverride = ["HDMI-A-1", "DP-2"]; Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]; Wallpaper.startupRestoreEnabled = false; Wallpaper.slideshowIntervalOverrideMs = 60000; @@ -63,7 +63,9 @@ ShellRoot { assignments: DesktopPreferences.get("wallpaperPerMonitor"), slideshowPath: Wallpaper.slideshowPath, shuffleBag: Wallpaper.shuffleBag, - slideshowTimerRunning: Wallpaper.slideshowTimerRunning + slideshowTimerRunning: Wallpaper.slideshowTimerRunning, + scanning: Wallpaper.scanning, + available: Wallpaper.available }); } } diff --git a/config/dot/quickshell/wallpaper-settings-harness.qml b/config/dot/quickshell/wallpaper-settings-harness.qml index 57fcd4a..21be75b 100644 --- a/config/dot/quickshell/wallpaper-settings-harness.qml +++ b/config/dot/quickshell/wallpaper-settings-harness.qml @@ -46,6 +46,16 @@ ShellRoot { return JSON.stringify(root.calls); } + function selectOutput(output: string): void { + controls.selectedOutput = output; + } + + function updateOutputs(outputs: string): void { + controls.outputs = outputs === "" ? [] : outputs.split("|"); + } + + function selectedOutput(): string { return controls.selectedOutput; } + function states(): string { picker.mode = "single"; const single = { diff --git a/tests/quickshell/display-transaction-contract b/tests/quickshell/display-transaction-contract index d13cf57..9d6d657 100755 --- a/tests/quickshell/display-transaction-contract +++ b/tests/quickshell/display-transaction-contract @@ -223,16 +223,18 @@ failed_state="$(wait_for '.busy == false and .awaiting == false')" jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \ || fail "failed apply did not retain a useful recovery message: $failed_state" -# If an output disconnects while a change is pending, rollback sends one -# transaction containing every output that is still connected. +# If an output disconnects while a change is pending, the screen-model observer +# refreshes topology and rolls back with only the still-connected output. This +# intentionally does not call Displays.refresh() through the public harness API. [[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \ || fail 'disconnect fixture could not start' wait_for '.canConfirm == true' >/dev/null jq '.[0:1]' "$monitor_state" >"$fixture/connected.json" mv "$fixture/connected.json" "$monitor_state" -run ipc --pid "$harness_pid" call displays-test refresh >/dev/null -wait_for '.layout | length == 1' >/dev/null -run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +run ipc --pid "$harness_pid" call displays-test setScreenModel '["DP-2"]' >/dev/null +wait_for '(.layout | length == 1) and .awaiting == false and .busy == false' >/dev/null +[[ "$(transaction_status | jq -c .primaryFirst)" == '["DP-2"]' ]] \ + || fail "primary-first monitor list did not reconcile after hot-unplug: $(transaction_status)" wait_for '.busy == false and .awaiting == false' >/dev/null disconnect_payload="$(tail -1 "$eval_log")" [[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \ diff --git a/tests/quickshell/lock-screen-helper-contract b/tests/quickshell/lock-screen-helper-contract index c705f36..4e8b11d 100755 --- a/tests/quickshell/lock-screen-helper-contract +++ b/tests/quickshell/lock-screen-helper-contract @@ -14,6 +14,9 @@ settings="$config_home/panama/settings.json" generated="$state_home/panama/hyprlock.conf" hyprlock_log="$fixture/hyprlock.log" theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" +shipped_wallpaper="$fixture_home/Pictures/Wallpapers/faroe_islands.jpg" +global_wallpaper="$fixture_home/Pictures/Wallpapers/global.jpg" +portrait_wallpaper="$fixture_home/Pictures/Wallpapers/portrait.jpg" fail() { printf 'lock screen helper contract: %s\n' "$1" >&2 @@ -28,6 +31,9 @@ trap cleanup EXIT mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \ "$config_home/hypr" "$state_home" "$test_bin" +printf 'shipped wallpaper\n' >"$shipped_wallpaper" +printf 'global wallpaper\n' >"$global_wallpaper" +printf 'portrait wallpaper\n' >"$portrait_wallpaper" cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \ "$config_home/hypr/hyprlock.conf.template" HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" \ @@ -131,19 +137,38 @@ write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}' run_helper generate rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue' -write_settings '{ - "lockBackgroundMode":"wallpaper", - "wallpaperMode":"per-monitor", - "wallpaperPath":"/images/global.jpg", - "wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"} -}' +write_settings "$(jq -n \ + --arg global "$global_wallpaper" \ + --arg portrait "$portrait_wallpaper" \ + '{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$global,wallpaperPerMonitor:{"DP-2":$portrait}}')" run_helper generate [[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output' -awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ +awk -v path="$portrait_wallpaper" '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \ || fail 'per-monitor wallpaper was not used for DP-2' -awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ +awk -v path="$global_wallpaper" '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \ || fail 'missing monitor assignment did not fall back to the global wallpaper' +# A selected but unreadable image never reaches hyprlock. One bounded warning +# covers both missing global and per-monitor choices while the shipped image is +# still readable. +write_settings "$(jq -n \ + --arg missing "$fixture/missing.jpg" \ + '{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$missing,wallpaperPerMonitor:{"DP-2":$missing}}')" +run_helper generate +[[ "$(rg -c "path = $shipped_wallpaper" "$generated")" -eq 2 ]] \ + || fail 'missing selected wallpapers did not fall back to the readable shipped image' +jq -e '.generated == true and .fallback == false + and .error == "One or more lock-screen wallpapers were unavailable; a safe fallback is in use."' \ + <<<"$(run_helper status)" >/dev/null \ + || fail 'missing wallpaper fallback did not report one bounded warning' + +# If no image is readable, screenshot is the only background source that +# hyprlock can still render safely. +rm -f "$shipped_wallpaper" +run_helper generate +[[ "$(rg -c 'path = screenshot' "$generated")" -eq 2 ]] \ + || fail 'missing wallpapers without the shipped image did not fall back to screenshots' + write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}' run_helper generate rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored' diff --git a/tests/quickshell/wallpaper-service-contract b/tests/quickshell/wallpaper-service-contract index 769ce4f..187316a 100755 --- a/tests/quickshell/wallpaper-service-contract +++ b/tests/quickshell/wallpaper-service-contract @@ -15,6 +15,8 @@ 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" fail() { printf 'wallpaper service contract: %s\n' "$1" >&2 @@ -46,9 +48,18 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$config_home/panama" "$state_home" "$test_bin" +mkdir -p "$config_home/panama" "$state_home" "$test_bin" \ + "$quoted_home/Pictures/Wallpapers" cp -a "$repo_dir/config/dot/quickshell" "$config_path" -printf '%s\n' '{}' >"$config_home/panama/settings.json" +printf 'quoted wallpaper\n' >"$quoted_wallpaper" +cat >"$config_home/panama/settings.json" <<'JSON' +{ + "displays": { + "DP-2": {"mode":"4500x3000@60.00","scale":1.5,"transform":0,"x":0,"y":0,"primary":true}, + "HDMI-A-1": {"mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":0,"primary":false} + } +} +JSON printf '%s\n' '{}' >"$active_state" : >"$command_log" : >"$control" @@ -92,6 +103,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 +125,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 +147,7 @@ for _ in $(seq 1 60); do sleep 0.1 done [[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated service harness process did not start' +wait_for_scan wait_idle >/dev/null : >"$command_log" diff --git a/tests/quickshell/wallpaper-settings-contract b/tests/quickshell/wallpaper-settings-contract index 657122a..53cd3c7 100755 --- a/tests/quickshell/wallpaper-settings-contract +++ b/tests/quickshell/wallpaper-settings-contract @@ -68,6 +68,15 @@ done [[ "$(qs_for_harness ipc call wallpaper-settings-test activate per-monitor /images/c.jpg)" \ == '["assign:DP-2:/images/c.jpg"]' ]] || fail 'Per-display mode did not assign the selected output' +qs_for_harness ipc call wallpaper-settings-test selectOutput HDMI-A-1 >/dev/null +qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2|HDMI-A-1' >/dev/null +[[ "$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" == 'HDMI-A-1' ]] \ + || fail 'a valid per-monitor selection was reset during topology reconciliation' +qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2' >/dev/null +selected_after_disconnect="$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" +[[ "$selected_after_disconnect" == 'DP-2' ]] \ + || fail "a disconnected per-monitor selection did not fall back to the primary output: $selected_after_disconnect" + states="$(qs_for_harness ipc call wallpaper-settings-test states)" jq -e '.selectedOutput == "DP-2" and .single == {"current":true,"selected":true}