Persist complete monitor layouts

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 2613768efd
commit d0d9e196b0
5 changed files with 139 additions and 12 deletions
+40 -3
View File
@@ -20,7 +20,10 @@
local prefs = require("prefs") local prefs = require("prefs")
-- Per-output overrides written by Panama Settings, keyed by output name: -- 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 -- Only mode, scale, and transform are read. Colour management and bit depth
-- stay here, because those are the settings with a documented reason attached -- stay here, because those are the settings with a documented reason attached
@@ -71,6 +74,26 @@ local function valid_transform(transform)
and transform <= 3 and transform <= 3
end 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) local function display_entry(output)
if type(output) ~= "string" or output == "" if type(output) ~= "string" or output == ""
or output:match("^[%w_.-]+$") == nil then or output:match("^[%w_.-]+$") == nil then
@@ -85,9 +108,23 @@ local function display_entry(output)
or not valid_transform(entry.transform) then or not valid_transform(entry.transform) then
return nil return nil
end 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 return entry
end 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_mode = "4500x3000@60"
local shipped_scale = 1.5 local shipped_scale = 1.5
local shipped_transform = 0 local shipped_transform = 0
@@ -96,7 +133,7 @@ local dp2 = display_entry("DP-2")
hl.monitor({ hl.monitor({
output = "DP-2", output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode, mode = dp2 and dp2.mode or shipped_mode,
position = "0x0", position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale, scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform, transform = dp2 and dp2.transform or shipped_transform,
@@ -119,7 +156,7 @@ for output, _ in pairs(displays) do
hl.monitor({ hl.monitor({
output = output, output = output,
mode = entry.mode, mode = entry.mode,
position = "auto", position = display_position(entry, "auto"),
scale = entry.scale, scale = entry.scale,
transform = entry.transform, transform = entry.transform,
}) })
@@ -1191,7 +1191,7 @@ Singleton {
}, },
// ── Display configuration ─────────────────────────────────────────── // ── Display configuration ───────────────────────────────────────────
// { "<output>": { mode, scale, transform } }, applied by // { "<output>": { mode, scale, transform, x, y, primary } }, applied by
// hypr/monitors.lua on top of the shipped values. Colour management and // hypr/monitors.lua on top of the shipped values. Colour management and
// bit depth are deliberately not here: those carry a documented // bit depth are deliberately not here: those carry a documented
// screencopy tradeoff that a settings page cannot explain at the moment // screencopy tradeoff that a settings page cannot explain at the moment
@@ -1200,7 +1200,7 @@ Singleton {
key: "displays", type: "json", def: ({}), group: "display", key: "displays", type: "json", def: ({}), group: "display",
internal: true, internal: true,
label: "Display configuration", label: "Display configuration",
detail: "Resolution, scale, and rotation per connected display" detail: "Resolution, scale, rotation, position, and primary display"
}, },
// ── Per-application notification rules ────────────────────────────── // ── Per-application notification rules ──────────────────────────────
@@ -20,6 +20,9 @@ ShellRoot {
mode: monitor ? monitor.mode : "", mode: monitor ? monitor.mode : "",
scale: monitor ? monitor.scale : 0, scale: monitor ? monitor.scale : 0,
transform: monitor ? monitor.transform : -1, 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, modes: monitor ? monitor.modes.length : 0,
awaiting: Displays.awaitingConfirmation, awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm, 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: ["[email protected]"]
},
{
name: "HDMI-A-1", description: "Second", width: 2560, height: 1440,
refreshRate: 60, scale: 1, transform: 0, x: 3140, y: 80,
availableModes: ["[email protected]"]
}
]), 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 { function applyBad(kind: string): bool {
const monitor = Displays.monitors[0]; const monitor = Displays.monitors[0];
if (!monitor) return false; if (!monitor) return false;
@@ -126,6 +126,16 @@ Singleton {
function parse(text: string, generation: int): void { function parse(text: string, generation: int): void {
try { try {
const raw = JSON.parse(text); 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 => { root.monitors = raw.map(monitor => {
const modes = root.normaliseModes(monitor.availableModes ?? []); const modes = root.normaliseModes(monitor.availableModes ?? []);
const width = monitor.width ?? 0; const width = monitor.width ?? 0;
@@ -144,6 +154,9 @@ Singleton {
mode: current?.mode ?? `${width}x${height}@${refreshRate}`, mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
scale: monitor.scale ?? 1, scale: monitor.scale ?? 1,
transform: monitor.transform ?? 0, 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 ?? "", currentFormat: monitor.currentFormat ?? "",
colorPreset: monitor.colorManagementPreset ?? "", colorPreset: monitor.colorManagementPreset ?? "",
vrr: monitor.vrr === true, vrr: monitor.vrr === true,
@@ -216,6 +229,17 @@ Singleton {
return root.monitors.find(monitor => monitor.name === name) ?? null; 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 { function modeParts(mode: string): var {
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/); const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!match) if (!match)
+46 -7
View File
@@ -42,7 +42,10 @@ for contract in \
'readonly property bool canConfirm:' \ 'readonly property bool canConfirm:' \
'function matchesRequest(' \ 'function matchesRequest(' \
'function scalesForMode(' \ '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" rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
done done
rg -Fq 'enabled: Displays.canConfirm' "$page" \ 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 # 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. # 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" rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
done done
@@ -79,9 +82,27 @@ package.preload["prefs"] = function()
return { return {
get = function() get = function()
return { return {
["DP-2"] = { mode = "not-a-mode", scale = -1, transform = 99 }, ["DP-2"] = {
["HDMI-A-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 1 }, mode = "4500x3000@60", scale = 1.5, transform = 0,
["BAD OUTPUT"] = { mode = "1920x1080@60", scale = 1, 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, 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"].mode == "4500x3000@60")
assert(by_output["DP-2"].scale == 1.5) assert(by_output["DP-2"].scale == 1.5)
assert(by_output["DP-2"].transform == 0) assert(by_output["DP-2"].transform == 0)
assert(by_output["HDMI-A-1"].mode == "1920x1080@60") assert(by_output["DP-2"].position == "0x0")
assert(by_output["HDMI-A-1"].scale == 1.5) 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"].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["BAD OUTPUT"] == nil)
assert(by_output[""] ~= nil) assert(by_output[""] ~= nil)
LUA 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 if [[ "${PANAMA_DISPLAYS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'displays contract: PASS (static)\n' printf 'displays contract: PASS (static)\n'
exit 0 exit 0
@@ -209,6 +241,13 @@ jq -e '
' <<<"$refresh_fixture" >/dev/null \ ' <<<"$refresh_fixture" >/dev/null \
|| fail "59.94 Hz and 60.00 Hz lost their distinct selection identity: $refresh_fixture" || 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 for _ in $(seq 1 50); do
[[ "$(status | jq -r .count)" != "0" ]] && break [[ "$(status | jq -r .count)" != "0" ]] && break
sleep 0.1 sleep 0.1