# 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 ;`, 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. // // 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. // // 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. // // 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. // // 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. // // 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" ```