diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index 9fe3fe6..5121fcf 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -20,7 +20,10 @@ local prefs = require("prefs") -- Per-output overrides written by Panama Settings, keyed by output name: --- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } } +-- { ["DP-2"] = { +-- mode = "3840x2160@60", scale = 2, transform = 0, +-- x = 0, y = 0, primary = true, +-- } } -- -- Only mode, scale, and transform are read. Colour management and bit depth -- stay here, because those are the settings with a documented reason attached @@ -71,6 +74,26 @@ local function valid_transform(transform) and transform <= 3 end +local function valid_coordinate(value) + return type(value) == "number" + and value == value + and value == math.floor(value) + and value >= -100000 + and value <= 100000 +end + +local function valid_position(entry) + return valid_coordinate(entry.x) and valid_coordinate(entry.y) +end + +local function valid_primary(entry) + return type(entry.primary) == "boolean" +end + +local function has_layout_fields(entry) + return entry.x ~= nil or entry.y ~= nil or entry.primary ~= nil +end + local function display_entry(output) if type(output) ~= "string" or output == "" or output:match("^[%w_.-]+$") == nil then @@ -85,9 +108,23 @@ local function display_entry(output) or not valid_transform(entry.transform) then return nil end + -- Legacy records have none of the layout fields and keep automatic + -- placement. A partially written extended record is unsafe: accepting its + -- mode but guessing its position could overlap or strand another output. + if has_layout_fields(entry) + and (not valid_position(entry) or not valid_primary(entry)) then + return nil + end return entry end +local function display_position(entry, fallback) + if entry ~= nil and has_layout_fields(entry) then + return string.format("%dx%d", entry.x, entry.y) + end + return fallback +end + local shipped_mode = "4500x3000@60" local shipped_scale = 1.5 local shipped_transform = 0 @@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2") hl.monitor({ output = "DP-2", mode = dp2 and dp2.mode or shipped_mode, - position = "0x0", + position = display_position(dp2, "0x0"), scale = dp2 and dp2.scale or shipped_scale, transform = dp2 and dp2.transform or shipped_transform, @@ -119,7 +156,7 @@ for output, _ in pairs(displays) do hl.monitor({ output = output, mode = entry.mode, - position = "auto", + position = display_position(entry, "auto"), scale = entry.scale, transform = entry.transform, }) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 86653e8..0397da0 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1191,7 +1191,7 @@ Singleton { }, // ── Display configuration ─────────────────────────────────────────── - // { "": { mode, scale, transform } }, applied by + // { "": { mode, scale, transform, x, y, primary } }, applied by // hypr/monitors.lua on top of the shipped values. Colour management and // bit depth are deliberately not here: those carry a documented // screencopy tradeoff that a settings page cannot explain at the moment @@ -1200,7 +1200,7 @@ Singleton { key: "displays", type: "json", def: ({}), group: "display", internal: true, label: "Display configuration", - detail: "Resolution, scale, and rotation per connected display" + detail: "Resolution, scale, rotation, position, and primary display" }, // ── Per-application notification rules ────────────────────────────── diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index df12479..b1dd0c1 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -20,6 +20,9 @@ ShellRoot { mode: monitor ? monitor.mode : "", scale: monitor ? monitor.scale : 0, transform: monitor ? monitor.transform : -1, + x: monitor ? monitor.x : 0, + y: monitor ? monitor.y : 0, + primary: monitor ? monitor.primary : false, modes: monitor ? monitor.modes.length : 0, awaiting: Displays.awaitingConfirmation, canConfirm: Displays.canConfirm, @@ -49,6 +52,30 @@ ShellRoot { }); } + function positionFixture(): string { + const previous = Displays.monitors; + Displays.parse(JSON.stringify([ + { + name: "DP-2", description: "Primary", width: 4500, height: 3000, + refreshRate: 60, scale: 1.5, transform: 0, x: 140, y: 80, + availableModes: ["4500x3000@60.00Hz"] + }, + { + name: "HDMI-A-1", description: "Second", width: 2560, height: 1440, + refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80, + availableModes: ["2560x1440@60.00Hz"] + } + ]), Displays.operationGeneration); + const result = JSON.stringify(Displays.monitors.map(monitor => ({ + name: monitor.name, + x: monitor.x, + y: monitor.y, + primary: monitor.primary + }))); + Displays.monitors = previous; + return result; + } + function applyBad(kind: string): bool { const monitor = Displays.monitors[0]; if (!monitor) return false; diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index 56e93f3..dc7b9f4 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -126,6 +126,16 @@ Singleton { function parse(text: string, generation: int): void { try { const raw = JSON.parse(text); + const stored = DesktopPreferences.get("displays"); + const persisted = stored && typeof stored === "object" ? stored : {}; + const persistedPrimaries = raw.filter(monitor => { + const entry = persisted[monitor.name ?? ""]; + return root.isPersistedLayoutEntry(entry) && entry.primary === true; + }); + const origin = raw.find(monitor => monitor.x === 0 && monitor.y === 0); + const primaryName = persistedPrimaries.length === 1 + ? persistedPrimaries[0].name + : (origin?.name ?? raw[0]?.name ?? ""); root.monitors = raw.map(monitor => { const modes = root.normaliseModes(monitor.availableModes ?? []); const width = monitor.width ?? 0; @@ -144,6 +154,9 @@ Singleton { mode: current?.mode ?? `${width}x${height}@${refreshRate}`, scale: monitor.scale ?? 1, transform: monitor.transform ?? 0, + x: Number.isInteger(monitor.x) ? monitor.x : 0, + y: Number.isInteger(monitor.y) ? monitor.y : 0, + primary: monitor.name === primaryName, currentFormat: monitor.currentFormat ?? "", colorPreset: monitor.colorManagementPreset ?? "", vrr: monitor.vrr === true, @@ -216,6 +229,17 @@ Singleton { return root.monitors.find(monitor => monitor.name === name) ?? null; } + function isPersistedLayoutEntry(entry: var): bool { + return !!entry && typeof entry === "object" + && root.modeParts(entry.mode) !== null + && Number.isFinite(entry.scale) && entry.scale > 0 + && Number.isInteger(entry.transform) + && entry.transform >= 0 && entry.transform <= 3 + && Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000 + && Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000 + && typeof entry.primary === "boolean"; + } + function modeParts(mode: string): var { const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/); if (!match) diff --git a/tests/quickshell/displays-contract.sh b/tests/quickshell/displays-contract.sh index 767e9cc..ce120a4 100755 --- a/tests/quickshell/displays-contract.sh +++ b/tests/quickshell/displays-contract.sh @@ -42,7 +42,10 @@ for contract in \ 'readonly property bool canConfirm:' \ 'function matchesRequest(' \ 'function scalesForMode(' \ - 'function isScaleClean('; do + '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 rg -Fq 'enabled: Displays.canConfirm' "$page" \ @@ -60,7 +63,7 @@ rg -Fq 'if (root.busy)' "$service" \ # 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' 'pairs(displays)'; do +for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract" done @@ -79,9 +82,27 @@ package.preload["prefs"] = function() return { get = function() return { - ["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 }, - ["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 }, - ["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, transform = 0 }, + ["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, + }, + ["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, + }, } end, } @@ -96,13 +117,24 @@ 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["HDMI-A-1"].mode == "1920x1080@60") -assert(by_output["HDMI-A-1"].scale == 1.5) +assert(by_output["DP-2"].position == "0x0") +assert(by_output["DP-2"].bitdepth == 10) +assert(by_output["DP-2"].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) LUA +rg -Fq 'Resolution, scale, rotation, position, and primary display' \ + "$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 @@ -209,6 +241,13 @@ jq -e ' ' <<<"$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