From 3c747053dd140ba4f2d53155939e4a895ec8c6e9 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 30 Oct 2026 20:37:06 +0100 Subject: 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. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/monitors/MonitorsModule.qml | 157 ++++++++++++++++++++++++++++ desktop/modules/monitors/MonitorsPage.qml | 23 ++++ 2 files changed, 180 insertions(+) create mode 100644 desktop/modules/monitors/MonitorsModule.qml create mode 100644 desktop/modules/monitors/MonitorsPage.qml (limited to 'desktop/modules') diff --git a/desktop/modules/monitors/MonitorsModule.qml b/desktop/modules/monitors/MonitorsModule.qml new file mode 100644 index 0000000..3ed9b22 --- /dev/null +++ b/desktop/modules/monitors/MonitorsModule.qml @@ -0,0 +1,157 @@ +// 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 } + } + } +} diff --git a/desktop/modules/monitors/MonitorsPage.qml b/desktop/modules/monitors/MonitorsPage.qml new file mode 100644 index 0000000..3df7b1c --- /dev/null +++ b/desktop/modules/monitors/MonitorsPage.qml @@ -0,0 +1,23 @@ +// 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 + } +} -- cgit v1.2.3