Krolyxon
Commit d48166e

Branches

Refs →

aurora.configquickshellauroraSetDisplay.qml

285 lines · 11.0 KB · QML

1import QtQuick
2import QtQuick.Layouts
3import Quickshell
4import Quickshell.Io
5
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
11Flickable {
12 id: flick
13 clip: true
14 contentWidth: width
15 contentHeight: col.implicitHeight
16 boundsBehavior: Flickable.StopAtBounds
17
18 readonly property string dir: Quickshell.env("HOME") + "/.config/aurora"
19 property var monitors: [] // hyprctl monitors all -j
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 }
24 property int countdown: 0
25 property string message: ""
26
27 function refresh() { monProc.running = true }
28 Component.onCompleted: refresh()
29
30 // only characters a monitor name / mode / position can contain, so nothing can break out of the Lua string
31 function q(s) { return '"' + String(s).replace(/[^A-Za-z0-9@x._\-]/g, "") + '"' }
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 }
40 }
41 function apply(m, change) {
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)
57 countdown = 15
58 tick.restart()
59 }
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 }
73 function revert() {
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")
88 }
89 function run(code) {
90 evalProc.command = ["hyprctl", "eval", code]
91 evalProc.running = true
92 }
93
94 Timer {
95 id: tick
96 interval: 1000
97 repeat: true
98 onTriggered: { if (--flick.countdown <= 0) flick.revert() }
99 }
100 Process {
101 id: monProc
102 command: ["hyprctl", "monitors", "all", "-j"]
103 stdout: StdioCollector {
104 onStreamFinished: { try { flick.monitors = JSON.parse(text) } catch (e) {} }
105 }
106 }
107 Process {
108 id: evalProc
109 stdout: StdioCollector {
110 onStreamFinished: {
111 var t = text.trim()
112 flick.message = t === "" || t === "ok" ? "" : t
113 refreshLater.restart()
114 }
115 }
116 }
117 Timer { id: refreshLater; interval: 800; onTriggered: flick.refresh() }
118
119 FileView {
120 id: jsonFile
121 path: flick.dir + "/monitors.json"
122 blockLoading: true
123 printErrors: false // no file until the first kept change
124 onLoaded: {
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
130 }
131 }
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 }
139
140 ColumnLayout {
141 id: col
142 width: flick.width
143 spacing: 8
144
145 // keep / revert bar after a monitor change
146 Rectangle {
147 Layout.fillWidth: true
148 visible: flick.countdown > 0
149 implicitHeight: 40
150 color: Theme.surface
151 border.width: 1
152 border.color: Theme.accent
153 RowLayout {
154 anchors.fill: parent
155 anchors.margins: 6
156 Text {
157 Layout.fillWidth: true
158 text: "keep this display setup? reverting in " + flick.countdown + "s"
159 color: Theme.fg
160 font.family: Theme.font
161 font.pixelSize: Theme.fontSize
162 }
163 IconBtn { icon: Theme.iCheck; label: "keep"; active: true; onClicked: flick.keep() }
164 IconBtn { icon: Theme.iRefresh; label: "revert"; onClicked: flick.revert() }
165 }
166 }
167 Text {
168 Layout.fillWidth: true
169 visible: flick.message !== ""
170 text: flick.message
171 color: Theme.err
172 wrapMode: Text.WordWrap
173 font.family: Theme.font
174 font.pixelSize: Theme.fontSize - 1
175 }
176
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
194 Repeater {
195 model: flick.monitors
196 delegate: ColumnLayout {
197 id: mon
198 required property var modelData
199 readonly property var m: modelData
200 readonly property string curMode: m.width + "x" + m.height
201 Layout.fillWidth: true
202 spacing: 6
203
204 SectionLabel { text: mon.m.name }
205 Text {
206 Layout.fillWidth: true
207 text: mon.m.description + (mon.m.disabled ? " · disabled"
208 : " · " + mon.curMode + " @ " + mon.m.refreshRate.toFixed(2) + "Hz · scale " + mon.m.scale
209 + " · at " + mon.m.x + "," + mon.m.y)
210 color: Theme.dim
211 elide: Text.ElideRight
212 font.family: Theme.font
213 font.pixelSize: Theme.fontSize - 1
214 }
215 Text {
216 visible: !mon.m.disabled
217 text: "mode"
218 color: Theme.fg
219 font.family: Theme.font
220 font.pixelSize: Theme.fontSize
221 }
222 Flow {
223 Layout.fillWidth: true
224 visible: !mon.m.disabled
225 spacing: 4
226 Repeater {
227 model: mon.m.availableModes
228 delegate: IconBtn {
229 required property string modelData
230 readonly property string mode: modelData.replace("Hz", "")
231 readonly property real hz: parseFloat(mode.split("@")[1])
232 implicitHeight: 24
233 label: mode
234 active: mode.split("@")[0] === mon.curMode && Math.abs(hz - mon.m.refreshRate) < 0.02
235 onClicked: if (!active) flick.apply(mon.m, { mode: mode })
236 }
237 }
238 }
239 Choice {
240 visible: !mon.m.disabled
241 label: "scale"
242 options: [{ value: 1, label: "100%" }, { value: 1.25, label: "125%" }, { value: 1.5, label: "150%" },
243 { value: 1.75, label: "175%" }, { value: 2, label: "200%" }]
244 value: Math.round(mon.m.scale * 100) / 100
245 onPicked: x => flick.apply(mon.m, { scale: x })
246 }
247 Field {
248 visible: !mon.m.disabled
249 label: "position"
250 placeholder: "1920x0"
251 value: mon.m.x + "x" + mon.m.y
252 onSubmitted: x => {
253 if (/^-?\d+x-?\d+$/.test(x.trim())) flick.apply(mon.m, { position: x.trim() })
254 else flick.message = "position must look like 1920x0"
255 }
256 }
257 }
258 }
259
260 SectionLabel { text: "brightness & colour" }
261 Knob {
262 visible: Bright.available
263 label: "laptop panel"
264 unit: "%"
265 from: 1
266 to: 100
267 value: Bright.percent
268 onMoved: x => Bright.setPercent(x)
269 }
270 Switch {
271 visible: Toggles.hasNightLight
272 label: "night light"
273 hint: "gammastep's warm colours (its schedule comes from ~/.config/gammastep)"
274 checked: Toggles.nightLight
275 onToggled: on => Toggles.setNightLight(on)
276 }
277 Text {
278 visible: !Toggles.hasNightLight
279 text: "night light: gammastep isn't running"
280 color: Theme.dim
281 font.family: Theme.font
282 font.pixelSize: Theme.fontSize - 1
283 }
284 }
285}