aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-10-30-monitors-page.md882
1 files changed, 882 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-30-monitors-page.md b/docs/superpowers/plans/2026-10-30-monitors-page.md
new file mode 100644
index 0000000..b020847
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-30-monitors-page.md
@@ -0,0 +1,882 @@
+# Monitors Settings Page Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A settings page, Hyprland > Monitors, that sets mode, scale, rotation, VRR and position for each connected monitor, with a to-scale drag layout, tried live and written to `~/.config/hypr/sections/monitors.lua` only on Keep.
+
+**Architecture:** Pure helpers in `Mon.js` (Lua generation, logical size, snapping, mode list), tested under node. `MonitorsModule.qml` reads `hyprctl monitors -j` into a draft, applies it with `hyprctl eval` one monitor at a time, and either writes the file (Keep) or runs `hyprctl reload` (Revert, 15s timeout, leaving the page). `MonitorsPage.qml` draws the canvas and one card per monitor.
+
+**Tech Stack:** Quickshell 0.3.1 QML, Qt 6.11, Hyprland 0.56.2 Lua config, node for the check.
+
+Spec: `docs/superpowers/specs/2026-10-30-monitors-page-design.md`.
+
+Facts measured on 2026-10-30, which the plan relies on:
+
+- `hyprctl eval 'hl.monitor({ ... })'` applies at once and prints `ok`, exit 0. A bad field prints `error: return hl.monitor(...);:1: hl.monitor: field 'transform': value 9 is more than the maximum of 7` and exits 7, changing nothing.
+- eval wraps its argument as `return <arg>;`, so one call takes one expression: each monitor is its own eval.
+- `position = "-1080x-960"`, `scale = "1"` or `1`, `vrr = 0` are all accepted.
+- `hyprctl reload` after an eval restores the file's settings (DP-3 eval'd to `-1080x-900`, reload put it back at `-1080,-960`).
+- Glyph `` (fa-desktop) exists in Inconsolata Nerd Font.
+
+Repo rules that bite here (from `AGENTS.md`): no `String.matchAll`, `Object.fromEntries` or `flatMap` in QML JS; `import QtQuick.Controls` unqualified shadows the directory's `Button.qml`, so import it `as C`; `Field.edited` fires on `editingFinished`, so read values on every change; a `shell.qml` edit needs `./reload.sh desktop` from the user's terminal, never from an agent call; every source file carries the GPLv2 header.
+
+---
+
+### Task 1: Mon.js helpers and their check
+
+**Files:**
+- Create: `desktop/modules/monitors/Mon.js`
+- Test: `desktop/modules/monitors/test_mon.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/monitors/test_mon.js`:
+
+```js
+// 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");
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/modules/monitors/test_mon.js`
+Expected: FAIL, `ENOENT: no such file or directory, open '.../Mon.js'`
+
+- [ ] **Step 3: Write the implementation**
+
+`desktop/modules/monitors/Mon.js`:
+
+```js
+.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)) };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node desktop/modules/monitors/test_mon.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/monitors/Mon.js desktop/modules/monitors/test_mon.js
+git commit -m "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)."
+```
+
+---
+
+### Task 2: MonitorsModule.qml and its registration
+
+**Files:**
+- Create: `desktop/modules/monitors/MonitorsModule.qml`
+- Create: `desktop/modules/monitors/MonitorsPage.qml` (a stub here, filled in Task 3)
+- Modify: `desktop/shell.qml` (one import, one entry after `OptionsModule {},` at line 99)
+
+- [ ] **Step 1: Write the module**
+
+`desktop/modules/monitors/MonitorsModule.qml`:
+
+```qml
+// 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.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import QtQuick
+import "../.."
+import "Mon.js" as M
+
+// The connected monitors, read from hyprctl monitors -j into a draft. Apply
+// runs each monitor's hl.monitor line through hyprctl eval, which applies it
+// at once or refuses it (exit 7) and changes nothing; then Keep writes
+// sections/monitors.lua, which this page owns whole, and Revert, or 15s with
+// no answer, is hyprctl reload, which reapplies the untouched file. A
+// setting that blanks a screen never reaches the file.
+Module {
+ id: mm
+ name: "monitors"
+ label: "Monitors"
+ // nf-fa-desktop, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Hyprland"
+ dirty: mm.changed
+ keywords: ["monitor", "display", "screen", "resolution", "refresh", "hz", "scale", "rotation", "vrr", "position", "layout"]
+
+ readonly property string file: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ + "/hypr/sections/monitors.lua"
+
+ // What hyprctl last reported, and the page's edit of it.
+ property var orig: []
+ property var draft: []
+ readonly property bool changed: JSON.stringify(mm.draft) !== JSON.stringify(mm.orig)
+ readonly property bool valid: mm.draft.every(d => /^\d+(\.\d+)?$/.test(d.scale) && +d.scale > 0)
+ // An Apply is live and waiting for Keep or Revert.
+ property bool pending: false
+ property int countdown: 0
+ property string error: ""
+ property string notice: ""
+ // The reload after a refusal must not replace the draft being fixed.
+ property bool skipRead: false
+
+ function refresh() { if (!mm.changed && !mm.pending) mm.read(); }
+
+ function read() {
+ mm.error = "";
+ readProc.running = false;
+ readProc.running = true;
+ }
+
+ property Process readProc: Process {
+ command: ["hyprctl", "monitors", "-j"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ let list;
+ try { list = JSON.parse(this.text); } catch (e) { return; }
+ mm.orig = list.map(M.draft);
+ mm.draft = list.map(M.draft);
+ }
+ }
+ }
+
+ // Keep's write, Revert's reload: both end in configreloaded.
+ property Connections reloads: Connections {
+ target: Hyprland
+ function onRawEvent(event) {
+ if (event.name !== "configreloaded" || mm.pending) return;
+ if (mm.skipRead) { mm.skipRead = false; return; }
+ mm.read();
+ }
+ }
+
+ // Changes one field of monitor i; a new array so bindings see it.
+ function set(i, key, value) {
+ const next = mm.draft.slice();
+ next[i] = Object.assign({}, next[i]);
+ next[i][key] = value;
+ mm.draft = next;
+ mm.notice = "";
+ }
+
+ function apply() {
+ mm.error = "";
+ mm.notice = "";
+ applyProc.command = ["sh", "-c", 'for l; do hyprctl eval "$l" || exit 7; done', "sh"]
+ .concat(mm.draft.map(M.lua));
+ applyProc.running = false;
+ applyProc.running = true;
+ }
+
+ property Process applyProc: Process {
+ stdout: StdioCollector { id: applyOut }
+ onExited: code => {
+ if (code !== 0) {
+ const line = applyOut.text.trim().split("\n").filter(l => l.startsWith("error:"))[0] || "";
+ mm.error = line.replace(/^error: .*?:1: /, "") || "Hyprland refused it.";
+ // An earlier monitor's eval may have gone through.
+ mm.skipRead = true;
+ Quickshell.execDetached(["hyprctl", "reload"]);
+ return;
+ }
+ mm.pending = true;
+ mm.countdown = 15;
+ tick.restart();
+ }
+ }
+
+ property Timer tick: Timer {
+ interval: 1000
+ repeat: true
+ onTriggered: if (--mm.countdown <= 0) mm.revert()
+ }
+
+ function keep() {
+ tick.stop();
+ mm.pending = false;
+ fv.setText(M.file(mm.draft));
+ mm.notice = "saved";
+ }
+
+ // Also what leaving the page while pending does.
+ function revert() {
+ tick.stop();
+ mm.pending = false;
+ Quickshell.execDetached(["hyprctl", "reload"]);
+ }
+
+ property FileView fv: FileView {
+ path: mm.file
+ atomicWrites: true
+ printErrors: false
+ }
+
+ Component.onCompleted: mm.read()
+
+ page: Component {
+ Page {
+ title: "Monitors"
+ actions: Component {
+ SaveBar { saveText: "Apply"; dirty: mm.changed && !mm.pending; valid: mm.valid
+ note: mm.valid ? "" : "Scale must be a positive number."
+ notice: mm.notice; error: mm.error; busy: mm.applyProc.running
+ onSave: mm.apply(); onDiscard: mm.read() }
+ }
+ MonitorsPage { width: parent.width; mod: mm }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Write a stub page so the module loads**
+
+`desktop/modules/monitors/MonitorsPage.qml`:
+
+```qml
+// 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.
+
+import QtQuick
+import "../.."
+
+Column {
+ id: page
+ required property QtObject mod
+ Text {
+ text: page.mod.draft.map(d => d.name + " " + d.mode).join(", ")
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+}
+```
+
+- [ ] **Step 3: Register it in shell.qml**
+
+In `desktop/shell.qml`, add with the other module imports (keep alphabetical with its neighbours, after `import "modules/mail"` or wherever `m` falls):
+
+```qml
+import "modules/monitors"
+```
+
+and after `OptionsModule {},` (line 99):
+
+```qml
+ MonitorsModule {},
+```
+
+- [ ] **Step 4: Ask the user to restart the desktop shell**
+
+A `shell.qml` edit needs a restart (AGENTS.md, Reloading). Do not run it from a tool call. Ask the user to run, in their terminal:
+
+```
+./reload.sh desktop
+```
+
+Then check the log for load errors:
+
+Run: `qs log -p desktop --log-times 2>/dev/null | grep -i "monitor\|error\|not a type" | tail -10`
+Expected: no line naming `MonitorsModule`, `MonitorsPage` or `Mon.js`.
+
+- [ ] **Step 5: Check the apply script outside the shell**
+
+The same loop the module runs, with the current settings (a no-op):
+
+Run: `sh -c 'for l; do hyprctl eval "$l" || exit 7; done' sh 'hl.monitor({ output = "DP-3", mode = "1920x1080@100.00", position = "-1080x-960", scale = "1", transform = 1 })' 'hl.monitor({ output = "DP-1", mode = "2560x1080@99.94", position = "0x-540", scale = "1", transform = 9 })'; echo "exit $?"; hyprctl reload`
+Expected: `ok`, then an `error: ... value 9 is more than the maximum of 7` line, then `exit 7`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/monitors/MonitorsModule.qml desktop/modules/monitors/MonitorsPage.qml desktop/shell.qml
+git commit -m "monitors: settings module, try live then keep or revert
+
+Apply evals each monitor's hl.monitor line, one call each because eval
+wraps its argument in a return. Keep writes sections/monitors.lua whole;
+Revert or 15s without an answer is hyprctl reload, which reapplies the
+untouched file. A refusal also reloads, since an earlier monitor's eval
+may have gone through."
+```
+
+---
+
+### Task 3: MonitorsPage.qml, the canvas and the cards
+
+**Files:**
+- Modify: `desktop/modules/monitors/MonitorsPage.qml` (replace the stub)
+
+- [ ] **Step 1: Write the page**
+
+Replace `desktop/modules/monitors/MonitorsPage.qml` with:
+
+```qml
+// 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.
+
+import QtQuick
+import QtQuick.Controls as C
+import "../.."
+import "../appearance" as A
+import "Mon.js" as M
+
+// The keep-or-revert bar while an Apply is live, the layout drawn to scale
+// (drag a monitor to move it; it snaps to the other's edges), and one card
+// per monitor: mode, scale, rotation, VRR, position.
+Column {
+ id: page
+ required property QtObject mod
+ spacing: 14
+
+ // Leaving the page with an Apply unanswered is a Revert.
+ Component.onDestruction: if (page.mod.pending) page.mod.revert()
+
+ // The stock ComboBox paints from the palette, light by default; these
+ // are the desktop's colours, as in qtmaildir's TagsKeysPage.
+ component Combo: C.ComboBox {
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ palette {
+ base: Theme.surface; window: Theme.base; button: Theme.surface
+ text: Theme.text; buttonText: Theme.text; windowText: Theme.text
+ highlight: Theme.accent; highlightedText: Theme.base
+ mid: Qt.alpha(Theme.text, 0.2); dark: Qt.alpha(Theme.text, 0.2); light: Theme.surface
+ }
+ }
+
+ component Label: Text {
+ width: 90
+ anchors.verticalCenter: parent.verticalCenter
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+
+ Rectangle {
+ visible: page.mod.pending
+ width: parent.width
+ height: 44
+ radius: 10
+ color: Qt.alpha(Theme.yellow, 0.18)
+ border.width: 1
+ border.color: Qt.alpha(Theme.yellow, 0.5)
+ Text {
+ anchors { left: parent.left; leftMargin: 14; verticalCenter: parent.verticalCenter }
+ text: "Keep these settings? Reverting in " + page.mod.countdown + "s"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ Row {
+ anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 8
+ Button { text: "Revert"; onClicked: page.mod.revert() }
+ Button { text: "Keep"; onClicked: page.mod.keep() }
+ }
+ }
+
+ // ---- layout ----
+ Rectangle {
+ id: canvas
+ width: parent.width
+ height: 260
+ radius: 10
+ color: Theme.surface
+
+ // Logical rectangles, and the fit of their bounds into the canvas.
+ readonly property var rects: page.mod.draft.map(d => {
+ const p = M.xy(d), s = M.size(d.mode, d.scale, d.transform);
+ return { x: p.x, y: p.y, w: s.w, h: s.h };
+ })
+ readonly property real minX: Math.min.apply(null, rects.map(r => r.x).concat([0]))
+ readonly property real minY: Math.min.apply(null, rects.map(r => r.y).concat([0]))
+ readonly property real maxX: Math.max.apply(null, rects.map(r => r.x + r.w).concat([1]))
+ readonly property real maxY: Math.max.apply(null, rects.map(r => r.y + r.h).concat([1]))
+ readonly property real k: Math.min((width - 40) / (maxX - minX), (height - 40) / (maxY - minY))
+ readonly property real ox: (width - (maxX - minX) * k) / 2
+ readonly property real oy: (height - (maxY - minY) * k) / 2
+
+ Repeater {
+ model: page.mod.draft.length
+ Rectangle {
+ id: mon
+ required property int index
+ readonly property var r: canvas.rects[index] ?? { x: 0, y: 0, w: 1, h: 1 }
+ readonly property var d: page.mod.draft[index]
+ // The drag offset in canvas pixels, zero when not dragging.
+ property real dx: 0
+ property real dy: 0
+ x: canvas.ox + (r.x - canvas.minX) * canvas.k + dx
+ y: canvas.oy + (r.y - canvas.minY) * canvas.k + dy
+ width: r.w * canvas.k
+ height: r.h * canvas.k
+ radius: 6
+ color: Qt.alpha(Theme.accent, drag.pressed ? 0.35 : 0.18)
+ border.width: 1
+ border.color: Qt.alpha(Theme.accent, 0.7)
+
+ Column {
+ anchors.centerIn: parent
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: mon.d ? mon.d.name : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: mon.d ? mon.d.model : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+
+ MouseArea {
+ id: drag
+ anchors.fill: parent
+ enabled: !page.mod.pending
+ cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor
+ property point start
+ onPressed: m => start = mapToItem(canvas, m.x, m.y)
+ onPositionChanged: m => {
+ const p = mapToItem(canvas, m.x, m.y);
+ mon.dx = p.x - start.x;
+ mon.dy = p.y - start.y;
+ }
+ onReleased: {
+ const moved = { x: mon.r.x + mon.dx / canvas.k, y: mon.r.y + mon.dy / canvas.k, w: mon.r.w, h: mon.r.h };
+ const others = canvas.rects.filter((o, i) => i !== mon.index);
+ const s = M.snap(moved, others, 64);
+ mon.dx = 0;
+ mon.dy = 0;
+ if (s.x !== mon.r.x || s.y !== mon.r.y) page.mod.set(mon.index, "position", s.x + "x" + s.y);
+ }
+ }
+ }
+ }
+ }
+
+ // ---- cards ----
+ // Keyed on orig, a new array on every read, so Discard and a reload
+ // rebuild the cards and their controls rebind to the draft: Toggle and
+ // ComboBox set their own checked and currentIndex on a click.
+ Repeater {
+ model: page.mod.orig
+ Rectangle {
+ id: card
+ required property int index
+ readonly property var d: page.mod.draft[index]
+ readonly property bool auto: !!d && M.AUTO.includes(d.position)
+ objectName: d ? "mon:" + d.name : ""
+ width: page.width
+ height: rows.implicitHeight + 28
+ radius: 10
+ color: Theme.surface
+ visible: !!d
+
+ Column {
+ id: rows
+ anchors { fill: parent; margins: 14 }
+ spacing: 8
+
+ Text {
+ text: card.d ? card.d.name + " " + card.d.make + " " + card.d.model : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+
+ Row {
+ spacing: 8
+ Label { text: "Mode" }
+ Combo {
+ width: 220
+ model: card.d ? card.d.modes.map(m => m.replace("@", " @ ") + " Hz") : []
+ currentIndex: card.d ? card.d.modes.indexOf(card.d.mode) : -1
+ onActivated: i => page.mod.set(card.index, "mode", card.d.modes[i])
+ }
+ }
+
+ Row {
+ spacing: 8
+ Label { text: "Scale" }
+ // Read on every change: a click on Apply does not end editing.
+ A.Field {
+ value: card.d ? card.d.scale : ""
+ onValueChanged: if (card.d && value !== card.d.scale) page.mod.set(card.index, "scale", value)
+ }
+ }
+
+ Row {
+ spacing: 8
+ Label { text: "Rotation" }
+ Repeater {
+ model: [0, 90, 180, 270]
+ A.Tab {
+ required property int modelData
+ required property int index
+ text: modelData + "°"
+ selected: !!card.d && card.d.transform % 4 === index
+ onClicked: page.mod.set(card.index, "transform", (card.d.transform >= 4 ? 4 : 0) + index)
+ }
+ }
+ A.Toggle {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "Flipped"
+ checked: !!card.d && card.d.transform >= 4
+ onToggled: page.mod.set(card.index, "transform", card.d.transform % 4 + (checked ? 4 : 0))
+ }
+ }
+
+ Row {
+ spacing: 8
+ Label { text: "VRR" }
+ A.Toggle {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "Adaptive sync"
+ checked: !!card.d && card.d.vrr
+ onToggled: page.mod.set(card.index, "vrr", checked)
+ }
+ }
+
+ Row {
+ spacing: 8
+ Label { text: "Position" }
+ Combo {
+ width: 220
+ model: ["exact"].concat(M.AUTO)
+ currentIndex: card.auto ? M.AUTO.indexOf(card.d.position) + 1 : 0
+ // Rebound after a pick, so a later drag to an exact
+ // position shows here.
+ onActivated: i => {
+ page.mod.set(card.index, "position", i === 0 ? card.d.x + "x" + card.d.y : M.AUTO[i - 1]);
+ currentIndex = Qt.binding(() => card.auto ? M.AUTO.indexOf(card.d.position) + 1 : 0);
+ }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !card.auto
+ text: card.d ? card.d.position.replace("x", ", ") + " drag in the layout to move" : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ }
+ }
+ }
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: "Apply tries the settings live. Keep writes sections/monitors.lua; Revert, leaving the page, "
+ + "or 15 seconds with no answer restores it. Only connected monitors are written."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+}
+```
+
+Notes: `A.Toggle` flips its own `checked` before emitting `toggled()` (no argument), so the handlers read `checked`. Choosing `exact` sets the position to where Hyprland last placed the monitor (`d.x`, `d.y`). `"-1080x-960".replace("x", ", ")` replaces only the separator, since the coordinates hold no `x`.
+
+- [ ] **Step 2: Wait for the hot reload and check the log**
+
+The page file already exists, so its change hot-reloads (no new file this time).
+
+Run: `stat -c %y desktop/modules/monitors/MonitorsPage.qml; qs log -p desktop --log-times 2>/dev/null | tail -8`
+Expected: a `Reloading configuration...` and `Configuration Loaded` after the file's mtime, and no warning naming `MonitorsPage.qml`. If no reload followed, make a whitespace-free content change to the file again (AGENTS.md: hot reload sometimes does not fire).
+
+- [ ] **Step 3: Ask the user to check it by eye**
+
+Visual checks are the user's (AGENTS.md). Ask them to open settings > Hyprland > Monitors and confirm:
+1. The layout shows DP-3 tall on the left, DP-1 wide on the right, labelled.
+2. Each card shows the current mode (DP-1 `2560x1080 @ 99.94 Hz`, DP-3 `1920x1080 @ 100.00 Hz`), scale 1, rotation (DP-3 90°), VRR off, position exact.
+3. Changing nothing leaves Apply disabled; dragging DP-3 a little and releasing near its old spot snaps back flush; Discard restores.
+4. A harmless real change, e.g. DP-3 dragged down 100px, then Apply: the countdown bar appears; let it run out and the layout returns. Repeat, and close the settings window during the countdown: it reverts too. Repeat and press Keep: `sections/monitors.lua` is rewritten and the change stays after `hyprctl reload`.
+
+- [ ] **Step 4: Check the written file after Keep**
+
+Run: `cat ~/.config/hypr/sections/monitors.lua; hyprctl configerrors`
+Expected: the header, then `-- DP-1: ...` and `-- DP-3: ...` each followed by one `hl.monitor({ ... })` line; `configerrors` empty.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/monitors/MonitorsPage.qml
+git commit -m "monitors: the layout canvas and the per-monitor cards
+
+Monitors drawn at logical size, fitted to the canvas; a drag moves one by
+an offset and on release snaps it within 64 logical px of the other's
+edges and writes an exact position. The cards set mode from the
+deduplicated list, scale (read on every change, since a click on Apply
+does not end editing), rotation and flip, VRR, and auto or exact
+position. Leaving the page with an Apply unanswered reverts it."
+```
+
+If the user's Keep in Step 3 rewrote `monitors.lua`, commit that in `hypr-theme` too:
+
+```bash
+git -C ~/Programming/GIT/hypr-theme add sections/monitors.lua
+git -C ~/Programming/GIT/hypr-theme commit -m "monitors: now written by the keystone Monitors page"
+```
+
+---
+
+### Task 4: README and AGENTS.md
+
+**Files:**
+- Create: `desktop/modules/monitors/README.md`
+- Modify: `AGENTS.md` (the `desktop/` line of the directory listing)
+
+- [ ] **Step 1: Write the README**
+
+`desktop/modules/monitors/README.md`:
+
+```markdown
+# monitors
+
+Settings > Hyprland > Monitors: the connected monitors, from `hyprctl
+monitors -j`.
+
+The layout draws each monitor at its logical size (rotated a quarter turn
+swaps width and height, scale divides both). Drag one to move it; on release
+it snaps to the other monitor's edges when within 64 logical pixels, and its
+position becomes exact. Each card sets the mode, from the monitor's own list
+with duplicates dropped, the scale, rotation and flip, VRR, and the position,
+exact or one of Hyprland's `auto-*` values.
+
+Apply runs each monitor's `hl.monitor({ ... })` through `hyprctl eval`, one
+call per monitor because eval wraps its argument in a `return`. A refused
+line (exit 7) changes nothing and shows its error; a reload follows, since an
+earlier monitor may have gone through. Accepted, the change is live and a bar
+asks to keep it. Keep writes `~/.config/hypr/sections/monitors.lua`, which
+this page owns and rewrites whole. Revert, leaving the page, or 15 seconds
+with no answer runs `hyprctl reload`, which reapplies the untouched file. A
+setting that blanks a screen never reaches the file.
+
+hyprctl only reports where a monitor ended up, so a monitor set to an `auto-*`
+position in the file reads back as exact.
+
+Not handled: disabling an output, mirroring, bit depth and colour management,
+outputs that are not connected (an unplugged monitor drops out of the file on
+Keep), renaming outputs. The island and the window switcher are tied to DP-1
+by name.
+
+## Check
+
+ node desktop/modules/monitors/test_mon.js
+```
+
+- [ ] **Step 2: Update AGENTS.md**
+
+In `AGENTS.md`, the `desktop/` entry of the directory listing currently ends:
+
+```
+ the assistant's daemon and presets, qtmaildir, git,
+ Hyprland's keyboard shortcuts and options, fonts
+```
+
+Change the second line to:
+
+```
+ Hyprland's keyboard shortcuts, options and monitors, fonts
+```
+
+- [ ] **Step 3: Run the check once more**
+
+Run: `node desktop/modules/monitors/test_mon.js`
+Expected: `ok`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/monitors/README.md AGENTS.md
+git commit -m "docs: the monitors settings page"
+```