Krolyxon
Commit 73f71f3

Branches

Refs →

aurora73f71f3

hypr: monitors as Hyprland rules, drop kanshi

kanshi's wlr-output-management state overrode every hl.monitor() rule,
so aurora's Display page could not change mode, scale or position.
source/monitors.lua holds the per-output specs and a layout
(extend/laptop/external) re-applied on hotplug; the Display page changes
them live and saves to ~/.config/aurora/monitors.{json,lua} on keep.
Author
krolyxon
Date
2026-10-22 20:26 +0530
Parent
1f888d7
Commit
73f71f313da9b4a493e8e75d6a6eb961baf47f30

+149 −84

4 files changed

  1. M .config/hypr/hyprland.lua +1 −1
  2. A .config/hypr/source/monitors.lua +54 −0
  3. M .config/quickshell/aurora/SetDisplay.qml +94 −82
  4. M packages/desktop.txt +0 −1
M .config/hypr/hyprland.lua View
@@ -19,7 +19,6 @@ hl.on("hyprland.start", function()
1919 hl.exec_cmd("wl-paste --type text --watch cliphist store")
2020 hl.exec_cmd("wl-paste --type image --watch cliphist store")
2121 hl.exec_cmd("hyprpm reload -n")
22− hl.exec_cmd("kanshi")
2322 -- aurora is a systemd unit: hand it the login env (.zprofile: SUDO_ASKPASS, EDITOR, XDG dirs, ...)
2423 hl.exec_cmd("dbus-update-activation-environment --systemd --all && systemctl --user start aurora")
2524 -- hl.exec_cmd("waybar")
@@ -43,6 +42,7 @@ smw.setup({
4342 -- -----------------------------------------------------
4443 -- SOURCE FILES
4544 -- -----------------------------------------------------
45+require("source.monitors")
4646 require("source.environment_variables")
4747 require("source.permissions")
4848 require("source.appearance")
A .config/hypr/source/monitors.lua View
@@ -0,0 +1,54 @@
1+-- -----------------------------------------------------
2+-- Monitors (replaced kanshi: its wlr-output-management state overrode every hl.monitor() rule)
3+-- -----------------------------------------------------
4+-- Defaults are below. aurora's settings app (Display) saves per-output changes and the layout to
5+-- ~/.config/aurora/monitors.lua (generated, not in the repo) and changes them live through
6+-- `hyprctl eval 'monitors.apply()'`.
7+-- layout: "extend" every connected output on
8+-- "laptop" only the laptop panel
9+-- "external" laptop panel off while another output is on (it comes back when that one is unplugged)
10+monitors = {
11+ laptop = "eDP-1",
12+ layout = "extend",
13+ outputs = {
14+ ["eDP-1"] = { mode = "highrr", position = "0x0", scale = 1 },
15+ ["HDMI-A-1"] = { mode = "1920x1080", position = "1920x0", scale = 1 },
16+ },
17+ fallback = { mode = "preferred", position = "auto", scale = 1 }, -- any other output
18+}
19+
20+local ok, saved = pcall(dofile, HOME .. "/.config/aurora/monitors.lua")
21+if ok and type(saved) == "table" then
22+ if saved.layout == "extend" or saved.layout == "laptop" or saved.layout == "external" then
23+ monitors.layout = saved.layout
24+ end
25+ for name, s in pairs(saved.outputs or {}) do monitors.outputs[name] = s end
26+end
27+
28+local function rule(name, s, off)
29+ -- disabled = false explicitly: hl.monitor() merges into the output's existing rule
30+ if off then return { output = name, disabled = true } end
31+ return { output = name, mode = s.mode, position = s.position, scale = s.scale, disabled = false }
32+end
33+
34+-- is an output other than the laptop panel (and `gone`, which is being unplugged) on?
35+local function externalOn(gone)
36+ for _, m in ipairs(hl.get_monitors()) do
37+ if m.name ~= monitors.laptop and m.name ~= gone then return true end
38+ end
39+ return false
40+end
41+
42+function monitors.apply(gone)
43+ local l = monitors.layout
44+ hl.monitor(rule("", monitors.fallback, l == "laptop"))
45+ for name, s in pairs(monitors.outputs) do
46+ if name ~= monitors.laptop then hl.monitor(rule(name, s, l == "laptop")) end
47+ end
48+ hl.monitor(rule(monitors.laptop, monitors.outputs[monitors.laptop] or monitors.fallback,
49+ l == "external" and externalOn(gone)))
50+end
51+
52+monitors.apply()
53+hl.on("monitor.added", function() monitors.apply() end)
54+hl.on("monitor.removed", function(m) monitors.apply(m.name) end)
M .config/quickshell/aurora/SetDisplay.qml View
@@ -3,8 +3,11 @@ import QtQuick.Layouts
33 import Quickshell
44 import Quickshell.Io
55
6−// Settings app > Display: monitors (live, with a keep/revert countdown), kanshi profiles, brightness, night light.
7−// Monitor changes are runtime only: kanshi re-applies its profile on hotplug, persistent layouts live in its config.
6+// Settings app > Display: layout + per-monitor mode/scale/position, brightness, night light.
7+// Monitors are Hyprland rules (hypr/source/monitors.lua, global `monitors`). A change is applied live with
8+// `hyprctl eval 'monitors.apply()'` behind a keep/revert countdown, and only saved on keep:
9+// ~/.config/aurora/monitors.json {"layout": "extend", "outputs": {"eDP-1": {mode, position, scale}}} (state)
10+// ~/.config/aurora/monitors.lua the same as a Lua table, dofile'd by monitors.lua
811 Flickable {
912 id: flick
1013 clip: true
@@ -12,55 +15,81 @@ Flickable {
1215 contentHeight: col.implicitHeight
1316 boundsBehavior: Flickable.StopAtBounds
1417
15− readonly property string kanshiFile: Quickshell.env("HOME") + "/.config/kanshi/config"
18+ readonly property string dir: Quickshell.env("HOME") + "/.config/aurora"
1619 property var monitors: [] // hyprctl monitors all -j
17− property var revertSpec: null // spec to restore if the change isn't kept
20+ property var saved: ({ layout: "extend", outputs: {} })
21+ property string layout: "extend" // live value, differs from saved.layout during the countdown
22+ property string revertCode: "" // lua that undoes the pending change
23+ property var pendingSave: null // what keep() stores: { layout } or { output, spec }
1824 property int countdown: 0
1925 property string message: ""
2026
2127 function refresh() { monProc.running = true }
2228 Component.onCompleted: refresh()
2329
24− // monitor -> its current spec
25− function specOf(m) {
26− if (m.disabled) return { output: m.name, disabled: true }
27− return { output: m.name, mode: m.width + "x" + m.height + "@" + m.refreshRate.toFixed(2),
28− position: m.x + "x" + m.y, scale: m.scale }
29− }
3030 // only characters a monitor name / mode / position can contain, so nothing can break out of the Lua string
3131 function q(s) { return '"' + String(s).replace(/[^A-Za-z0-9@x._\-]/g, "") + '"' }
32− function lua(spec) {
33− if (spec.disabled) return "hl.monitor({ output = " + q(spec.output) + ", disabled = true })"
34− return "hl.monitor({ output = " + q(spec.output) + ", mode = " + q(spec.mode) + ", position = "
35− + q(spec.position) + ", scale = " + Number(spec.scale) + " })"
32+ function luaSpec(s) {
33+ return "{ mode = " + q(s.mode) + ", position = " + q(s.position) + ", scale = " + (Number(s.scale) || 1) + " }"
34+ }
35+ function setOutputCode(name, s) { return "monitors.outputs[" + q(name) + "] = " + luaSpec(s) + "; monitors.apply()" }
36+ function setLayoutCode(l) { return "monitors.layout = " + q(l) + "; monitors.apply()" }
37+
38+ function specOf(m) {
39+ return { mode: m.width + "x" + m.height + "@" + m.refreshRate.toFixed(2), position: m.x + "x" + m.y, scale: m.scale }
3640 }
3741 function apply(m, change) {
38− var spec
39− if (change.disabled) spec = { output: m.name, disabled: true }
40− else if (m.disabled) spec = { output: m.name, mode: "preferred", position: "auto", scale: 1 }
41− else {
42− spec = specOf(m)
43− for (var k in change) spec[k] = change[k]
44− }
45− revertSpec = specOf(m)
46− run(lua(spec))
42+ var spec = specOf(m)
43+ for (var k in change) spec[k] = change[k]
44+ begin(setOutputCode(m.name, spec), setOutputCode(m.name, specOf(m)), { output: m.name, spec: spec })
45+ }
46+ function setLayout(l) {
47+ if (l === layout) return
48+ var old = layout
49+ layout = l
50+ begin(setLayoutCode(l), setLayoutCode(old), { layout: l })
51+ }
52+ function begin(code, undo, save) {
53+ if (pendingSave) keep() // a second change while counting down: the first one evidently works
54+ revertCode = undo
55+ pendingSave = save
56+ run(code)
4757 countdown = 15
4858 tick.restart()
4959 }
50− function keep() { revertSpec = null; countdown = 0; tick.stop() }
60+ function keep() {
61+ if (pendingSave) {
62+ var o = { layout: saved.layout, outputs: Object.assign({}, saved.outputs) }
63+ if (pendingSave.layout) o.layout = pendingSave.layout
64+ else o.outputs[pendingSave.output] = pendingSave.spec
65+ saved = o
66+ save()
67+ }
68+ pendingSave = null
69+ revertCode = ""
70+ countdown = 0
71+ tick.stop()
72+ }
5173 function revert() {
52− if (revertSpec) run(lua(revertSpec))
53− keep()
74+ if (revertCode !== "") run(revertCode)
75+ if (pendingSave && pendingSave.layout) layout = saved.layout
76+ pendingSave = null
77+ revertCode = ""
78+ countdown = 0
79+ tick.stop()
80+ }
81+ function save() {
82+ jsonFile.setText(JSON.stringify(saved, null, 2) + "\n")
83+ var lines = []
84+ for (var n in saved.outputs) lines.push(" [" + q(n) + "] = " + luaSpec(saved.outputs[n]) + ",")
85+ luaFile.setText("-- generated by aurora's settings app (SetDisplay.qml); edit there, not here\n"
86+ + "return {\n layout = " + q(saved.layout) + ",\n outputs = {\n" + lines.join("\n")
87+ + (lines.length ? "\n" : "") + " },\n}\n")
5488 }
5589 function run(code) {
5690 evalProc.command = ["hyprctl", "eval", code]
5791 evalProc.running = true
5892 }
59− function enabledCount() {
60− var n = 0
61− for (var i = 0; i < monitors.length; i++) if (!monitors[i].disabled) n++
62− return n
63− }
6493
6594 Timer {
6695 id: tick
@@ -85,20 +114,28 @@ Flickable {
85114 }
86115 }
87116 }
88− Timer { id: refreshLater; interval: 600; onTriggered: flick.refresh() }
117+ Timer { id: refreshLater; interval: 800; onTriggered: flick.refresh() }
89118
90− // kanshi profiles: `profile NAME {` lines
91− property var profiles: []
92119 FileView {
93− path: flick.kanshiFile
94− printErrors: false
120+ id: jsonFile
121+ path: flick.dir + "/monitors.json"
122+ blockLoading: true
123+ printErrors: false // no file until the first kept change
95124 onLoaded: {
96− var out = [], re = /^\s*profile\s+(\S+)\s*\{/gm, mm
97− var t = text()
98− while ((mm = re.exec(t)) !== null) out.push(mm[1])
99− flick.profiles = out
125+ try {
126+ var j = JSON.parse(text())
127+ flick.saved = { layout: j.layout || "extend", outputs: j.outputs || {} }
128+ } catch (e) {}
129+ flick.layout = flick.saved.layout
100130 }
101131 }
132+ FileView { // write-only
133+ id: luaFile
134+ path: flick.dir + "/monitors.lua"
135+ preload: false
136+ printErrors: false
137+ onSaveFailed: err => flick.message = "could not write monitors.lua (error " + err + ")"
138+ }
102139
103140 ColumnLayout {
104141 id: col
@@ -137,6 +174,23 @@ Flickable {
137174 font.pixelSize: Theme.fontSize - 1
138175 }
139176
177+ SectionLabel { text: "layout" }
178+ Choice {
179+ label: "screens"
180+ options: [{ value: "extend", label: "extend" }, { value: "laptop", label: "laptop only" },
181+ { value: "external", label: "external only" }]
182+ value: flick.layout
183+ onPicked: x => flick.setLayout(x)
184+ }
185+ Text {
186+ Layout.fillWidth: true
187+ text: "applies whenever a monitor is plugged in. external only turns the laptop panel back on when the last external screen is unplugged."
188+ color: Theme.dim
189+ wrapMode: Text.WordWrap
190+ font.family: Theme.font
191+ font.pixelSize: Theme.fontSize - 1
192+ }
193+
140194 Repeater {
141195 model: flick.monitors
142196 delegate: ColumnLayout {
@@ -158,15 +212,6 @@ Flickable {
158212 font.family: Theme.font
159213 font.pixelSize: Theme.fontSize - 1
160214 }
161− Switch {
162− label: "enabled"
163− hint: !mon.m.disabled && flick.enabledCount() <= 1 ? "the only active monitor can't be turned off" : ""
164− checked: !mon.m.disabled
165− onToggled: on => {
166− if (!on && flick.enabledCount() <= 1) return
167− flick.apply(mon.m, { disabled: !on })
168− }
169− }
170215 Text {
171216 visible: !mon.m.disabled
172217 text: "mode"
@@ -212,39 +257,6 @@ Flickable {
212257 }
213258 }
214259
215− SectionLabel { text: "profiles (kanshi)" }
216− Text {
217− Layout.fillWidth: true
218− text: "changes above are temporary: kanshi re-applies its profile when monitors are plugged in."
219− color: Theme.dim
220− wrapMode: Text.WordWrap
221− font.family: Theme.font
222− font.pixelSize: Theme.fontSize - 1
223− }
224− Flow {
225− Layout.fillWidth: true
226− spacing: 4
227− Repeater {
228− model: flick.profiles
229− delegate: IconBtn {
230− required property string modelData
231− implicitHeight: 26
232− icon: Theme.iMonitor
233− label: modelData
234− onClicked: {
235− Quickshell.execDetached(["kanshictl", "switch", modelData])
236− refreshLater.restart()
237− }
238− }
239− }
240− IconBtn {
241− implicitHeight: 26
242− icon: Theme.iConsole
243− label: "edit config"
244− onClicked: Quickshell.execDetached(["sh", "-c", 'exec foot -e "${EDITOR:-nvim}" "$1"', "sh", flick.kanshiFile])
245− }
246− }
247−
248260 SectionLabel { text: "brightness & colour" }
249261 Knob {
250262 visible: Bright.available
M packages/desktop.txt View
@@ -33,7 +33,6 @@ wireplumber
3333
3434 # Apps the configs/keybinds assume
3535 foot
36−kanshi
3736 nsxiv
3837 xdg-utils
3938 curl