#!/usr/bin/env bash # Display configuration. # # This is the only setting in Panama that can leave the user unable to SEE the # screen well enough to undo it: a mode the panel cannot show, or a scale that # makes everything unreadable, is not recoverable through the UI that caused it. # # So the property under test is not "can it change the resolution" but "does an # unconfirmed change always come back". A regression here is not a broken # feature, it is a user staring at a blank monitor. # # * an unconfirmed change reverts on its own, and stores nothing # * a confirmed change is what writes to the settings store # * a mode, scale, rotation, or output the compositor did not offer is refused # before anything is applied # # The compositor is the live one -- there is no way to test this otherwise -- # but preferences are isolated, and every path restores the display it started # from. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" harness="$repo_dir/config/dot/quickshell/displays-harness.qml" service="$repo_dir/config/dot/quickshell/services/Displays.qml" page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml" settings_page="$repo_dir/config/dot/quickshell/modules/settings/SettingsPage.qml" monitors_lua="$repo_dir/config/dot/hypr/monitors.lua" fail() { printf 'displays contract: %s\n' "$1" >&2 exit 1 } # Keep is unavailable until compositor readback exactly matches the request. for contract in \ 'property var pendingRequestedLayout:' \ 'property var revertExpectedLayout:' \ 'property bool revertVerificationActive:' \ 'property int revertGeneration:' \ 'readonly property bool canConfirm:' \ 'function matchesLayout(' \ 'function scalesForMode(' \ 'function isScaleClean(' \ 'x: Number.isInteger(monitor.x)' \ 'y: Number.isInteger(monitor.y)' \ 'primary: monitor.name === primaryName'; do rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract" done # The rest of the record rides the same transaction. Colour and mirroring are # verified by readback like geometry; the VRR override is applied and never # verified, because the readback field is live adaptive-sync state rather than # the configured policy. Either way they are part of currentLayout, which is # what stops one apply from clobbering another field's value. for contract in 'colorProfile' 'bitdepth' 'sdrBrightness' 'mirrorOf' 'vrrMode'; do rg -Fq "$contract" "$service" || fail "the extended display record is missing: $contract" done rg -Fq 'enabled: Displays.canConfirm' "$page" \ || fail 'Keep is enabled before the display change is verified' 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' # 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' rg -Fq 'if (root.busy)' "$service" \ || fail 'the display service accepts a new apply while another operation is busy' # Stored JSON is untyped at field level, so the Lua startup consumer is the # final validation boundary and must support every named output it accepts. for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' \ 'color_profile' 'bitdepth_value' 'vrr_value' 'sdr_value' 'mirror_value' 'pairs(displays)'; do rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract" done # SettingsPage headers are genuinely pinned outside its scrolling surface. python3 - "$settings_page" <<'PY' || fail 'SettingsPage header is not pinned outside the Flickable' import sys text = open(sys.argv[1], encoding="utf-8").read() loader = text.find("id: pinnedHeader") flickable = text.find("id: pageScroll") if loader < 0 or flickable < 0 or loader > flickable: raise SystemExit(1) PY MONITORS_LUA="$monitors_lua" lua - <<'LUA' || fail 'monitor startup accepted invalid persisted geometry or ignored a named output' package.preload["prefs"] = function() return { get = function() return { ["DP-2"] = { mode = "4500x3000@60", scale = 1.5, transform = 0, x = 0, y = 0, primary = true, }, ["HDMI-A-1"] = { mode = "2560x1440@60", scale = 1, transform = 1, x = 3000, y = 0, primary = false, vrrMode = -1, }, ["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 }, ["PARTIAL-1"] = { mode = "1920x1080@60", scale = 1, transform = 0, x = 4440, }, ["BAD-PRIMARY"] = { mode = "1920x1080@60", scale = 1, transform = 0, x = 4440, y = 0, primary = "yes", }, ["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0, x = 4440, y = 0, primary = false, }, -- The extended record, as Settings stores it. ["DP-3"] = { mode = "1920x1080@60", scale = 1, transform = 0, x = 4440, y = 0, primary = false, colorProfile = "srgb", bitdepth = 10, sdrBrightness = 1.2, sdrSaturation = 1.0, vrrMode = 2, mirrorOf = "DP-2", }, -- Every new field impossible at once. Geometry is fine, so the -- display is still configured; the bad fields drop out one by -- one exactly as an invalid position does. ["DP-4"] = { mode = "1920x1080@60", scale = 1, transform = 0, x = 6360, y = 0, primary = false, colorProfile = "neon", bitdepth = 12, sdrBrightness = 9, sdrSaturation = -1, vrrMode = 7, mirrorOf = "BAD OUTPUT", }, } end, } end local calls = {} hl = { monitor = function(value) table.insert(calls, value) end } assert(loadfile(os.getenv("MONITORS_LUA")))() local by_output = {} for _, value in ipairs(calls) do by_output[value.output] = value end assert(by_output["DP-2"].mode == "4500x3000@60") assert(by_output["DP-2"].scale == 1.5) assert(by_output["DP-2"].transform == 0) assert(by_output["DP-2"].position == "0x0") -- The panel-specific color policy rides the description-matched rule, not the -- connector: a stranger's monitor on DP-2 must not inherit the Kuycon's mode -- or its 10-bit request. local kuycon = by_output["desc:GVT Kuycon P20"] assert(kuycon ~= nil) assert(kuycon.mode == "4500x3000@60") assert(kuycon.bitdepth == 10) assert(kuycon.cm == "auto") assert(by_output["HDMI-A-1"].mode == "2560x1440@60") assert(by_output["HDMI-A-1"].scale == 1) assert(by_output["HDMI-A-1"].transform == 1) assert(by_output["HDMI-A-1"].position == "3000x0") assert(by_output["LEGACY-1"].position == "auto") assert(by_output["PARTIAL-1"] == nil) assert(by_output["BAD-PRIMARY"] == nil) assert(by_output["BAD OUTPUT"] == nil) assert(by_output[""] ~= nil) -- The extended record reaches the compositor under Hyprland's own key names. assert(by_output["DP-3"].cm == "srgb") assert(by_output["DP-3"].bitdepth == 10) assert(by_output["DP-3"].sdrbrightness == 1.2) assert(by_output["DP-3"].vrr == 2) assert(by_output["DP-3"].mirror == "DP-2") -- A mirrored output shows its target's picture in its target's place, so the -- saved position is not ours to ask for. assert(by_output["DP-3"].position == "auto") -- Neutral SDR saturation is left out rather than written: a rule that names it -- pins the display to it. assert(by_output["DP-3"].sdrsaturation == nil) -- -1 means "follow the global policy", which is the absence of an override, -- not an override with a special value. Writing a vrr key here would silently -- take this display out of the gaming policy it is meant to follow. assert(by_output["HDMI-A-1"].vrr == nil) -- Bad fields drop, the display survives. This is the same fallback invalid -- geometry gets, and it matters more here: refusing the whole entry over an -- unreadable colour profile would lose the resolution too. assert(by_output["DP-4"] ~= nil) assert(by_output["DP-4"].position == "6360x0") assert(by_output["DP-4"].cm == nil) assert(by_output["DP-4"].bitdepth == nil) assert(by_output["DP-4"].sdrbrightness == nil) assert(by_output["DP-4"].vrr == nil) assert(by_output["DP-4"].mirror == nil) LUA rg -Fq 'Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring' \ "$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \ || fail 'the display preference does not document complete layout persistence' if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then printf 'displays contract: PASS (static)\n' exit 0 fi config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)" run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; } status() { run ipc call displays-test status; } # The shipped geometry, read from the Hyprland config rather than from the # running compositor. # # Everything below captures "original" from what it observes at start, which is # correct only if the display is already in a good state. A previous run that # failed mid-revert leaves the display changed, and the next run then captures # THAT as the original and faithfully restores the desktop to a broken value. # One flake becomes permanent. So refuse to run from a state that does not match # what the config says, rather than laundering it. shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \ "$repo_dir/config/dot/hypr/monitors.lua" | head -1)" [[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered' if [[ -n "$shipped_scale" ]]; then live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')" if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'" fi fi original_mode="" original_scale="" original_transform="" original_width="" original_height="" original_refresh="" original_x="" original_y="" monitor_name="" monitor_state() { hyprctl -j monitors | jq -c --arg output "$monitor_name" '.[] | select(.name == $output)' } display_is_restored() { local current current="$(monitor_state)" [[ -n "$current" ]] || return 1 jq -e \ --argjson width "$original_width" \ --argjson height "$original_height" \ --argjson refresh "$original_refresh" \ --argjson scale "$original_scale" \ --argjson transform "$original_transform" \ --argjson x "$original_x" \ --argjson y "$original_y" \ '.width == $width and .height == $height and ((.refreshRate - $refresh) | fabs) < 0.01 and ((.scale - $scale) | fabs) < 0.001 and .transform == $transform and .x == $x and .y == $y' <<<"$current" >/dev/null } restore_display() { [[ -n "$original_mode" ]] || return 0 hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", position = \"${original_x}x${original_y}\", scale = $original_scale, transform = $original_transform })" >/dev/null \ || return 1 for _ in $(seq 1 50); do display_is_restored && return 0 sleep 0.2 done return 1 } stop_harness() { # Kill by PID, never `pkill -f displays-harness`: that pattern also matches # any shell whose command line contains this script's text, which includes # the invoking shell itself. [[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true rm -rf "$config_home" } cleanup() { local status=$? trap - EXIT if ! restore_display; then printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \ "$monitor_name" "$original_mode" "$original_x" "$original_y" \ "$original_scale" "$original_transform" >&2 status=1 fi stop_harness exit "$status" } trap cleanup EXIT XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null harness_pid="" for _ in $(seq 1 40); do run ipc show 2>/dev/null | rg -q '^target displays-test$' && break sleep 0.1 done run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start' harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" refresh_fixture="$(run ipc call displays-test refreshIdentityFixture)" jq -e ' .count == 2 and .modes == ["1920x1080@60.00", "1920x1080@59.94"] and .selected == ["1920x1080@59.94"] ' <<<"$refresh_fixture" >/dev/null \ || fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture" position_fixture="$(run ipc call displays-test positionFixture)" jq -e '. == [ {"name":"DP-2","x":140,"y":80,"primary":true}, {"name":"HDMI-A-1","x":3140,"y":80,"primary":false} ]' <<<"$position_fixture" >/dev/null \ || fail "monitor positions or primary selection were parsed incorrectly: $position_fixture" for _ in $(seq 1 50); do [[ "$(status | jq -r .count)" != "0" ]] && break sleep 0.1 done state="$(status)" monitor_name="$(jq -r .name <<<"$state")" [[ -n "$monitor_name" ]] || fail "no display was detected: $state" original_mode="$(jq -r .mode <<<"$state")" original_width="$(jq -r .width <<<"$state")" original_height="$(jq -r .height <<<"$state")" original_refresh="$(jq -r .refresh <<<"$state")" original_scale="$(jq -r .scale <<<"$state")" original_transform="$(jq -r .transform <<<"$state")" original_x="$(jq -r .x <<<"$state")" original_y="$(jq -r .y <<<"$state")" [[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes' # ── Anything the compositor did not offer is refused before applying ───────── while IFS= read -r kind; do [[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \ || fail "an invalid $kind was accepted" [[ "$(status | jq -r .awaiting)" == "false" ]] \ || fail "an invalid $kind left a change pending" done <<'KINDS' mode scale transform output dirtyScale KINDS # The display must not have moved for any of those. now="$(status)" [[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale' # An immediate Revert may race both the apply process and its first readback. # It must queue until both are clear, then verify the original generation. target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }') [[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \ || fail 'the immediate-revert fixture could not apply' run ipc call displays-test revertChange >/dev/null immediate_reverted=false for _ in $(seq 1 60); do # settled, not awaiting: the compositor can look restored while the # service's revert verification is still reading back, and an apply in # that window is refused as busy. See the settled field in the harness. if display_is_restored && [[ "$(status | jq -r .settled)" == "true" ]]; then immediate_reverted=true break fi sleep 0.2 done [[ "$immediate_reverted" == true ]] \ || fail 'an immediate Revert raced the apply/readback and did not restore the display' # ── An unconfirmed change reverts on its own and stores nothing ────────────── [[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \ || fail 'a valid scale change was refused' applied=false for _ in $(seq 1 30); do [[ "$(monitor_state | jq -r '.scale')" == "$target_scale" ]] && { applied=true; break; } sleep 0.2 done [[ "$applied" == true ]] || fail 'the scale change never reached the compositor' [[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation' [[ "$(status | jq -r .canConfirm)" == "true" ]] || fail 'an applied change was never verified by compositor readback' # Wait out the countdown. This is the whole point of the contract. reverted=false for _ in $(seq 1 120); do if [[ "$(monitor_state | jq -r '.scale')" == "$original_scale" ]]; then reverted=true break fi sleep 0.5 done [[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display' [[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert' [[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store' # ── A confirmed change is what writes ──────────────────────────────────────── # Wait out the revert readback before applying again -- same race as above. for _ in $(seq 1 40); do [[ "$(status | jq -r .settled)" == "true" ]] && break sleep 0.2 done [[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \ || fail 'the confirmed-change fixture could not apply' # Hyprland can apply and read back a change faster than two IPC round trips, so # this live test cannot reliably observe the pre-readback state. The # fake-compositor contract deterministically holds that boundary open and # proves Keep refuses it; this path proves a real readback eventually enables # and persists Keep on the physical display. verified=false for _ in $(seq 1 30); do [[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; } sleep 0.2 done [[ "$verified" == true ]] || fail 'the confirmed change never became safe to keep' [[ "$(run ipc call displays-test confirmChange)" == "true" ]] \ || fail 'Keep refused a verified display change' sleep 0.6 [[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state' [[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change' store="$config_home/panama/settings.json" jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \ || fail 'the confirmed change is not in the settings store' # ── Forgetting clears it ───────────────────────────────────────────────────── run ipc call displays-test forget >/dev/null sleep 0.6 [[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting' # ── Restoring an arrangement when a display comes back ─────────────────────── # # hypr/monitors.lua applies stored per-output entries when the compositor reads # its config, and never again. A monitor plugged in an hour later used to get # the compositor's automatic placement instead of the arrangement this machine # was told to use, and the only way back was to open Settings and apply it # again. Docking should not cost you your desk. # # Driven through plannedRestore, which is the decision with no side effects -- # applying a real layout here would drive the live compositor. # # Three cases, and the last two matter most: a wrong answer is a screen you # cannot see well enough to fix, so it refuses rather than guesses. # Single-line and space-free: the IPC call splits its arguments on whitespace, # so a pretty-printed fixture arrives as several arguments instead of one. two_screens='[{"name":"DP-2","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":0,"y":0,"availableModes":["1920x1080@60.00Hz","2560x1440@60.00Hz"]},{"name":"HDMI-A-1","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":1920,"y":0,"availableModes":["1920x1080@60.00Hz"]}]' # base64 because `qs ipc call` splits a JSON array of several objects into one # argument per object, which makes a two-monitor fixture look like an extra # argument and the call is refused for arity. b64() { printf '%s' "$1" | base64 -w0; } plan() { run ipc call displays-test restorePlan "$(b64 "$1")" "$(b64 "$2")"; } # Nothing stored: the compositor's placement stands. result="$(plan "$two_screens" '{}')" jq -e '.action == "none"' <<<"$result" >/dev/null \ || fail "with nothing stored, Panama wanted to change the arrangement: $result" # Stored and already correct: still nothing to do, so a reconnect does not # push a layout the compositor is already showing. result="$(plan "$two_screens" '{"DP-2":{"mode":"1920x1080@60.00","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')" jq -e '.action == "none"' <<<"$result" >/dev/null \ || fail "an arrangement that already matches was re-applied anyway: $result" # Stored and different: restore it, with the stored scale and position. result="$(plan "$two_screens" '{"DP-2":{"mode":"2560x1440@60.00","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')" jq -e '.action == "apply" and (.layout[] | select(.name == "DP-2") | .mode == "2560x1440@60.00")' \ <<<"$result" >/dev/null \ || fail "a stored arrangement was not restored when the display reconnected: $result" # A mode this panel does not offer. Same connector, different hardware -- DP-1 # on one dock is not DP-1 on another. It must refuse rather than ask the # compositor for a mode that does not exist. result="$(plan "$two_screens" '{"DP-2":{"mode":"3840x2160@120.00","scale":1,"transform":0,"x":0,"y":0,"primary":true}}')" jq -e '.action == "refuse"' <<<"$result" >/dev/null \ || fail "a stored mode the connected panel does not offer was requested anyway: $result" # Undocked: the stored primary is gone. The layout that survives must still # name exactly one primary, or DisplayLayout.validate refuses it outright and # the machine keeps whatever it happens to have. one_screen='[{"name":"DP-2","width":1920,"height":1080,"refreshRate":60.0,"scale":1.0,"transform":0,"x":0,"y":0,"availableModes":["1920x1080@60.00Hz","2560x1440@60.00Hz"]}]' result="$(plan "$one_screen" '{"DP-2":{"mode":"2560x1440@60.00","scale":1,"transform":0,"x":0,"y":0,"primary":false},"HDMI-A-1":{"mode":"1920x1080@60.00","scale":1,"transform":0,"x":1920,"y":0,"primary":true}}')" jq -e '.action == "apply" and ([.layout[] | select(.primary)] | length == 1)' <<<"$result" >/dev/null \ || fail "after undocking, the restored layout did not name exactly one primary: $result" jq -e '[.layout[] | .name] == ["DP-2"]' <<<"$result" >/dev/null \ || fail "the restored layout mentions a display that is not connected: $result" restore_display || fail 'the final cleanup could not restore and verify the original display' original_mode="" stop_harness trap - EXIT printf 'displays contract: PASS\n'