From ca5b313ae55b393b993488e83e90eeadf6ada38c Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 30 Oct 2026 20:33:03 +0100 Subject: docs: plan for the monitors settings page Four tasks: Mon.js with its node check, the module with try, keep and revert, the canvas and cards, then the README and AGENTS.md. The JS in task 1 was run from the plan and passes. Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-30-monitors-page.md | 882 +++++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-monitors-page.md (limited to 'docs/superpowers/plans/2026-10-30-monitors-page.md') 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 ;`, 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" +``` -- cgit v1.2.3