Model multi-monitor layout geometry
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/display-layout-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-display-layout-state.XXXXXX)"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'display layout contract: %s\n' "$1" >&2
|
||||||
|
[[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||||
|
else
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'geometry harness did not launch'
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||||
|
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
|
||||||
|
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||||
|
&& qs_for_harness ipc show 2>/dev/null | rg -q '^target display-layout-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'geometry harness process did not start'
|
||||||
|
|
||||||
|
status="$(qs_for_harness ipc call display-layout-test status)"
|
||||||
|
jq -e '.valid == true
|
||||||
|
and .sizes == [{"width":3000,"height":2000},{"width":1440,"height":2560}]
|
||||||
|
and .normalized == [
|
||||||
|
{"name":"DP-2","x":0,"y":0,"primary":true},
|
||||||
|
{"name":"HDMI-A-1","x":3000,"y":0,"primary":false}]
|
||||||
|
and .bounds == {"x":0,"y":0,"width":4440,"height":2560}
|
||||||
|
and .near == 3000 and .far == 3017
|
||||||
|
and (.canvasRects | length) == 2
|
||||||
|
and ((.bounds.width / .bounds.height) - (4440 / 2560) | fabs) < 0.000001' \
|
||||||
|
<<<"$status" >/dev/null || fail "geometry output was wrong: $status"
|
||||||
|
|
||||||
|
invalid="$(qs_for_harness ipc call display-layout-test invalid)"
|
||||||
|
jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid"
|
||||||
|
|
||||||
|
printf 'display layout contract: PASS\n'
|
||||||
Reference in New Issue
Block a user