Model multi-monitor layout geometry

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 101797f8f0
commit 2613768efd
3 changed files with 273 additions and 0 deletions
@@ -0,0 +1,50 @@
import Quickshell
import Quickshell.Io
import QtQuick
import "services/DisplayLayout.js" as DisplayLayout
ShellRoot {
readonly property var fixture: [
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
]
IpcHandler {
target: "display-layout-test"
function status(): string {
const normalized = DisplayLayout.normalize(fixture);
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
const near = normalized.map(record => Object.assign({}, record));
near[1].x = 3016;
const far = normalized.map(record => Object.assign({}, record));
far[1].x = 3017;
return JSON.stringify({
valid: DisplayLayout.validate(fixture),
sizes: fixture.map(DisplayLayout.logicalSize),
normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })),
bounds: canvas.bounds,
canvasScale: canvas.scale,
canvasRects: canvas.rects,
near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x,
far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x
});
}
function invalid(): string {
const base = fixture.map(record => Object.assign({}, record));
const cases = [];
const add = layout => cases.push(DisplayLayout.validate(layout));
add([base[0], Object.assign({}, base[1], { name: "DP-2" })]);
add(base.map(record => Object.assign({}, record, { primary: false })));
add(base.map(record => Object.assign({}, record, { primary: true })));
add([Object.assign({}, base[0], { x: 0.5 }), base[1]]);
add([Object.assign({}, base[0], { scale: 0 }), base[1]]);
add([Object.assign({}, base[0], { transform: 4 }), base[1]]);
add([Object.assign({}, base[0], { width: Infinity }), base[1]]);
add([Object.assign({}, base[0], { width: 0 }), base[1]]);
return JSON.stringify(cases);
}
}
}
@@ -0,0 +1,166 @@
function logicalSize(record) {
if (!record)
return { width: 0, height: 0 };
const width = Number(record.width);
const height = Number(record.height);
const scale = Number(record.scale);
const transform = Number(record.transform);
if (!Number.isFinite(width) || !Number.isFinite(height)
|| !Number.isFinite(scale) || scale <= 0)
return { width: 0, height: 0 };
const rotated = transform === 1 || transform === 3;
return {
width: (rotated ? height : width) / scale,
height: (rotated ? width : height) / scale
};
}
function validCoordinate(value) {
return Number.isFinite(value) && Number.isInteger(value)
&& value >= -100000 && value <= 100000;
}
function validate(layout) {
if (!Array.isArray(layout) || layout.length === 0)
return false;
const names = {};
let primaryCount = 0;
for (const record of layout) {
if (!record || typeof record.name !== "string"
|| !/^[A-Za-z0-9_.-]+$/.test(record.name)
|| names[record.name])
return false;
names[record.name] = true;
if (!Number.isFinite(record.width) || record.width <= 0
|| !Number.isFinite(record.height) || record.height <= 0
|| !Number.isFinite(record.scale) || record.scale <= 0
|| !Number.isInteger(record.transform)
|| record.transform < 0 || record.transform > 3
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|| typeof record.primary !== "boolean")
return false;
const size = logicalSize(record);
if (!Number.isFinite(size.width) || size.width <= 0
|| !Number.isFinite(size.height) || size.height <= 0)
return false;
if (record.primary)
primaryCount += 1;
}
return primaryCount === 1;
}
function cloneLayout(layout) {
return (layout || []).map(record => Object.assign({}, record));
}
function normalize(layout) {
const result = cloneLayout(layout);
const primary = result.find(record => record.primary === true);
if (!primary)
return result;
const anchorX = primary.x;
const anchorY = primary.y;
for (const record of result) {
record.x -= anchorX;
record.y -= anchorY;
}
return result;
}
function bounds(layout) {
if (!Array.isArray(layout) || layout.length === 0)
return { x: 0, y: 0, width: 0, height: 0 };
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const record of layout) {
const size = logicalSize(record);
left = Math.min(left, record.x);
top = Math.min(top, record.y);
right = Math.max(right, record.x + size.width);
bottom = Math.max(bottom, record.y + size.height);
}
return { x: left, y: top, width: right - left, height: bottom - top };
}
function snap(layout, movingName, threshold) {
const result = cloneLayout(layout);
const moving = result.find(record => record.name === movingName);
if (!moving)
return result;
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
const movingSize = logicalSize(moving);
const movingXEdges = [moving.x, moving.x + movingSize.width];
const movingYEdges = [moving.y, moving.y + movingSize.height];
const stationary = result
.filter(record => record.name !== movingName)
.sort((left, right) => left.name.localeCompare(right.name));
let bestX = null;
let bestY = null;
for (const record of stationary) {
const size = logicalSize(record);
const xEdges = [record.x, record.x + size.width];
const yEdges = [record.y, record.y + size.height];
for (const source of movingXEdges) {
for (const target of xEdges) {
const delta = target - source;
const distance = Math.abs(delta);
if (distance <= limit && (bestX === null || distance < bestX.distance))
bestX = { delta, distance };
}
}
for (const source of movingYEdges) {
for (const target of yEdges) {
const delta = target - source;
const distance = Math.abs(delta);
if (distance <= limit && (bestY === null || distance < bestY.distance))
bestY = { delta, distance };
}
}
}
if (bestX !== null)
moving.x += bestX.delta;
if (bestY !== null)
moving.y += bestY.delta;
return result;
}
function canvasRects(layout, canvasWidth, canvasHeight, padding) {
const desktopBounds = bounds(layout);
const inset = Math.max(0, Number(padding) || 0);
const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2);
const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2);
const scale = desktopBounds.width > 0 && desktopBounds.height > 0
? Math.min(availableWidth / desktopBounds.width,
availableHeight / desktopBounds.height)
: 0;
const contentWidth = desktopBounds.width * scale;
const contentHeight = desktopBounds.height * scale;
const originX = inset + (availableWidth - contentWidth) / 2;
const originY = inset + (availableHeight - contentHeight) / 2;
return {
bounds: desktopBounds,
scale,
rects: (layout || []).map(record => {
const size = logicalSize(record);
return {
name: record.name,
x: originX + (record.x - desktopBounds.x) * scale,
y: originY + (record.y - desktopBounds.y) * scale,
width: size.width * scale,
height: size.height * scale,
primary: record.primary === true
};
})
};
}