aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-30 20:37:06 +0100
committerDanilo M. <danix@danix.xyz>2026-10-30 20:37:06 +0100
commit3c747053dd140ba4f2d53155939e4a895ec8c6e9 (patch)
tree98dd0c938d45ab0ba1aaba0b66c861c7ce585b4c
parent29d0c022b1557cef6b93efbc76734b816b92f62e (diff)
downloadquickshell-3c747053dd140ba4f2d53155939e4a895ec8c6e9.tar.gz
quickshell-3c747053dd140ba4f2d53155939e4a895ec8c6e9.zip
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 <noreply@anthropic.com>
-rw-r--r--desktop/modules/monitors/MonitorsModule.qml157
-rw-r--r--desktop/modules/monitors/MonitorsPage.qml23
-rw-r--r--desktop/shell.qml2
3 files changed, 182 insertions, 0 deletions
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. <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 }
+ }
+ }
+}
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. <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
+ }
+}
diff --git a/desktop/shell.qml b/desktop/shell.qml
index d1dc7dc..660c4a5 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -31,6 +31,7 @@ import "modules/git"
import "modules/hyprland"
import "modules/kdeconnect"
import "modules/mail"
+import "modules/monitors"
import "modules/network"
import "modules/notes"
import "modules/notifications"
@@ -97,6 +98,7 @@ ShellRoot {
ShellsModule {},
BindsModule {},
OptionsModule {},
+ MonitorsModule {},
ObsScenesModule {},
AssistantModule { id: asstSettings },
PresetsModule {},