| 1 | import QtQuick |
| 2 | import QtQuick.Layouts |
| 3 | import Quickshell |
| 4 | import 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 |
| 11 | Flickable { |
| 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 | } |