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")
-- 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,
})
@@ -1191,7 +1191,7 @@ Singleton {
},
// ── 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
// 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 ──────────────────────────────
@@ -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: ["[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 {
const monitor = Displays.monitors[0];
if (!monitor) return false;
@@ -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)