aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-30 20:34:19 +0100
committerDanilo M. <danix@danix.xyz>2026-10-30 20:34:19 +0100
commitffe1a7ccdd51f760fa1e742c0d09cc229a405544 (patch)
tree40c7828f339139fd936ab2521e3eb396c12b21fc
parentca5b313ae55b393b993488e83e90eeadf6ada38c (diff)
downloadquickshell-ffe1a7ccdd51f760fa1e742c0d09cc229a405544.tar.gz
quickshell-ffe1a7ccdd51f760fa1e742c0d09cc229a405544.zip
monitors: pure helpers for the settings page
The Lua for a monitor and for monitors.lua, logical size, edge snapping and the mode list, with the duplicates the ASUS reports collapsed and the current mode matched by nearest refresh (99.943 against 99.94). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--desktop/modules/monitors/Mon.js100
-rw-r--r--desktop/modules/monitors/test_mon.js70
2 files changed, 170 insertions, 0 deletions
diff --git a/desktop/modules/monitors/Mon.js b/desktop/modules/monitors/Mon.js
new file mode 100644
index 0000000..9b5e158
--- /dev/null
+++ b/desktop/modules/monitors/Mon.js
@@ -0,0 +1,100 @@
+.pragma library
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Pure helpers for the Monitors page, tested by test_mon.js under node.
+// No String.matchAll, Object.fromEntries or flatMap: the shell's engine has
+// none of them (AGENTS.md).
+
+// Hyprland's automatic positions, as the card offers them.
+const AUTO = ["auto", "auto-left", "auto-right", "auto-up", "auto-down",
+ "auto-center-left", "auto-center-right", "auto-center-up", "auto-center-down"];
+
+// availableModes ("1920x1080@120.00Hz") without the suffix, duplicates
+// dropped: the ASUS lists 120Hz twice.
+function modes(avail) {
+ const out = [];
+ for (const a of avail) {
+ const m = a.replace(/Hz$/, "");
+ if (!out.includes(m)) out.push(m);
+ }
+ return out;
+}
+
+// The entry of list at w x h with the refresh nearest r: refreshRate reads
+// 99.943 against a listed 99.94. Built from the numbers when none matches.
+function current(list, w, h, r) {
+ let best = "", dist = Infinity;
+ for (const m of list) {
+ const p = /^(\d+)x(\d+)@([\d.]+)$/.exec(m);
+ if (!p || +p[1] !== w || +p[2] !== h) continue;
+ const e = Math.abs(+p[3] - r);
+ if (e < dist) { dist = e; best = m; }
+ }
+ return best || w + "x" + h + "@" + r.toFixed(2);
+}
+
+// A monitor from hyprctl monitors -j as the page edits it. x and y are
+// where Hyprland put it, kept to draw an auto position.
+function draft(m) {
+ const list = modes(m.availableModes);
+ return { name: m.name, make: m.make, model: m.model, modes: list,
+ mode: current(list, m.width, m.height, m.refreshRate),
+ scale: String(m.scale), transform: m.transform, vrr: !!m.vrr,
+ position: m.x + "x" + m.y, x: m.x, y: m.y };
+}
+
+// Logical size: an odd transform is a quarter turn, scale divides.
+function size(mode, scale, transform) {
+ const p = /^(\d+)x(\d+)/.exec(mode);
+ let w = +p[1], h = +p[2];
+ if (transform % 2) { const t = w; w = h; h = t; }
+ return { w: w / +scale, h: h / +scale };
+}
+
+// The layout position: exact XxY, or where Hyprland last put an auto one.
+function xy(d) {
+ const p = /^(-?\d+)x(-?\d+)$/.exec(d.position);
+ return p ? { x: +p[1], y: +p[2] } : { x: d.x, y: d.y };
+}
+
+// One monitor as one Lua expression, for hyprctl eval and the file alike.
+function lua(d) {
+ return 'hl.monitor({ output = "' + d.name + '", mode = "' + d.mode + '", position = "' + d.position
+ + '", scale = "' + d.scale + '", transform = ' + d.transform + (d.vrr ? ", vrr = 1" : "") + " })";
+}
+
+const HEADER = "-- Monitors, written by the keystone settings window (Hyprland > Monitors).\n"
+ + "-- Rewritten whole on Keep: change them there, an edit here is replaced.\n";
+
+function file(list) {
+ return HEADER + list.map(d => "\n-- " + d.name + ": " + d.make + " " + d.model + "\n" + lua(d) + "\n").join("");
+}
+
+// The candidate within t of v nearest to it, else v.
+function nearest(v, cands, t) {
+ let best = v, dist = t;
+ for (const c of cands) {
+ if (Math.abs(c - v) <= dist) { dist = Math.abs(c - v); best = c; }
+ }
+ return best;
+}
+
+// r ({x, y, w, h}, logical) moved so an edge within t lies on an edge of one
+// of others: beside it, or aligned with it. Each axis on its own, rounded.
+function snap(r, others, t) {
+ const xs = [], ys = [];
+ for (const o of others) {
+ xs.push(o.x - r.w, o.x + o.w, o.x, o.x + o.w - r.w);
+ ys.push(o.y - r.h, o.y + o.h, o.y, o.y + o.h - r.h);
+ }
+ return { x: Math.round(nearest(r.x, xs, t)), y: Math.round(nearest(r.y, ys, t)) };
+}
diff --git a/desktop/modules/monitors/test_mon.js b/desktop/modules/monitors/test_mon.js
new file mode 100644
index 0000000..6b12ee7
--- /dev/null
+++ b/desktop/modules/monitors/test_mon.js
@@ -0,0 +1,70 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/modules/monitors/test_mon.js
+// Mon.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Mon.js", "utf8").replace(/^\.pragma library$/m, "");
+const M = new Function(src + "\nreturn { modes, current, draft, size, xy, lua, file, snap, AUTO };")();
+
+// Duplicates collapse, order kept, the Hz suffix dropped.
+assert.deepStrictEqual(M.modes(["1920x1080@60.00Hz", "1920x1080@120.00Hz", "1920x1080@120.00Hz", "1280x720@60.00Hz"]),
+ ["1920x1080@60.00", "1920x1080@120.00", "1280x720@60.00"]);
+
+// The nearest refresh at the same size, not the first.
+const lg = ["2560x1080@59.98", "2560x1080@99.94", "2560x1080@74.99", "1920x1080@99.94"];
+assert.strictEqual(M.current(lg, 2560, 1080, 99.943), "2560x1080@99.94");
+// A mode the list lacks is written from the live numbers.
+assert.strictEqual(M.current(lg, 3440, 1440, 60), "3440x1440@60.00");
+
+// A hyprctl monitor as a draft.
+const asus = { name: "DP-3", make: "ASUS", model: "VG279Q3A", width: 1920, height: 1080, refreshRate: 100,
+ scale: 1, transform: 1, vrr: false, x: -1080, y: -960,
+ availableModes: ["1920x1080@100.00Hz", "1920x1080@60.00Hz"] };
+const d = M.draft(asus);
+assert.deepStrictEqual([d.mode, d.scale, d.transform, d.vrr, d.position, d.x, d.y],
+ ["1920x1080@100.00", "1", 1, false, "-1080x-960", -1080, -960]);
+
+// Logical size: an odd transform swaps, scale divides.
+assert.deepStrictEqual(M.size("1920x1080@100.00", "1", 1), { w: 1080, h: 1920 });
+assert.deepStrictEqual(M.size("2560x1440@60.00", "1.25", 0), { w: 2048, h: 1152 });
+
+// Exact positions parse, auto ones fall back to where Hyprland put it.
+assert.deepStrictEqual(M.xy({ position: "-1080x-960", x: 0, y: 0 }), { x: -1080, y: -960 });
+assert.deepStrictEqual(M.xy({ position: "auto-center-left", x: 5, y: 6 }), { x: 5, y: 6 });
+assert.ok(M.AUTO.includes("auto-center-right"));
+
+// One line per monitor; vrr only when on.
+assert.strictEqual(M.lua(d),
+ 'hl.monitor({ output = "DP-3", mode = "1920x1080@100.00", position = "-1080x-960", scale = "1", transform = 1 })');
+assert.ok(M.lua(Object.assign({}, d, { vrr: true })).endsWith("transform = 1, vrr = 1 })"));
+
+// The file: header, then a comment and a line per monitor.
+const f = M.file([d]);
+assert.ok(f.startsWith("-- Monitors, written by the keystone settings window"));
+assert.ok(f.includes("-- DP-3: ASUS VG279Q3A\n" + M.lua(d) + "\n"));
+
+// Snapping: DP-1 is 2560x1080 at 0,-540. A 1080x1920 monitor dropped with
+// its right edge 30px short of DP-1's left edge snaps flush; 200px short
+// does not.
+const dp1 = [{ x: 0, y: -540, w: 2560, h: 1080 }];
+assert.deepStrictEqual(M.snap({ x: -1110, y: -950, w: 1080, h: 1920 }, dp1, 64), { x: -1080, y: -950 });
+assert.deepStrictEqual(M.snap({ x: -1280, y: -950, w: 1080, h: 1920 }, dp1, 64), { x: -1280, y: -950 });
+// Tops align when close.
+assert.deepStrictEqual(M.snap({ x: -1080, y: -520, w: 1080, h: 1920 }, dp1, 64), { x: -1080, y: -540 });
+// Results are integers.
+assert.deepStrictEqual(M.snap({ x: -1280.6, y: 3000.4, w: 1080, h: 1920 }, dp1, 64), { x: -1281, y: 3000 });
+
+console.log("ok");