| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | |
| 6 | // Hyprland options edited from the settings app. Only what the user changed is stored, as overrides: |
| 7 | // ~/.config/aurora/hypr.json {"general:gaps_in": 5, ...} (state, read back here) |
| 8 | // ~/.config/aurora/hypr.lua return { general = { gaps_in = 5 } } (dofile'd at the end of hyprland.lua) |
| 9 | // A change is applied live with `hyprctl eval 'hl.config({...})'` and survives reloads/logins through hypr.lua. |
| 10 | // reset(opt) drops the override and reloads Hyprland's config, so the repo value comes back. |
| 11 | Singleton { |
| 12 | id: root |
| 13 | readonly property string dir: Quickshell.env("HOME") + "/.config/aurora" |
| 14 | |
| 15 | // every option the settings pages show: path -> type (int | float | bool | str) |
| 16 | readonly property var types: ({ |
| 17 | "general:gaps_in": "int", "general:gaps_out": "int", "general:border_size": "int", |
| 18 | "general:layout": "str", "general:resize_on_border": "bool", |
| 19 | "decoration:rounding": "int", "decoration:active_opacity": "float", "decoration:inactive_opacity": "float", |
| 20 | "decoration:dim_inactive": "bool", "decoration:shadow:enabled": "bool", |
| 21 | "decoration:blur:enabled": "bool", "decoration:blur:size": "int", "decoration:blur:passes": "int", |
| 22 | "decoration:blur:xray": "bool", |
| 23 | "animations:enabled": "bool", |
| 24 | "master:mfact": "float", "master:new_status": "str", "master:new_on_top": "bool", "master:orientation": "str", |
| 25 | "dwindle:preserve_split": "bool", "dwindle:smart_split": "bool", |
| 26 | "misc:on_focus_under_fullscreen": "int", "misc:exit_window_retains_fullscreen": "bool", |
| 27 | "misc:focus_on_activate": "bool", |
| 28 | "input:kb_layout": "str", "input:kb_variant": "str", "input:kb_options": "str", |
| 29 | "input:repeat_rate": "int", "input:repeat_delay": "int", "input:sensitivity": "float", |
| 30 | "input:accel_profile": "str", "input:follow_mouse": "int", "input:natural_scroll": "bool", |
| 31 | "input:left_handed": "bool", |
| 32 | "input:touchpad:natural_scroll": "bool", "input:touchpad:tap_to_click": "bool", |
| 33 | "input:touchpad:disable_while_typing": "bool", "input:touchpad:scroll_factor": "float", |
| 34 | "gestures:workspace_swipe_invert": "bool", "gestures:workspace_swipe_distance": "int", |
| 35 | "cursor:inactive_timeout": "int", "cursor:hide_on_key_press": "bool" |
| 36 | }) |
| 37 | |
| 38 | property var values: ({}) // current value of every option in `types` (from getoption) |
| 39 | property var overrides: ({}) // only the changed ones |
| 40 | property string message: "" // last hyprctl error, shown by the pages |
| 41 | property bool loaded: false |
| 42 | |
| 43 | function get(opt) { return values[opt] } |
| 44 | function isSet(opt) { return overrides.hasOwnProperty(opt) } |
| 45 | |
| 46 | function set(opt, v) { |
| 47 | if (!types.hasOwnProperty(opt)) return |
| 48 | var o = Object.assign({}, overrides), c = Object.assign({}, values) |
| 49 | o[opt] = v |
| 50 | c[opt] = v |
| 51 | overrides = o |
| 52 | values = c |
| 53 | pending[opt] = v |
| 54 | applyTimer.restart() // coalesces slider drags into one hyprctl call |
| 55 | saveTimer.restart() |
| 56 | } |
| 57 | function reset(opt) { resetAll([opt]) } |
| 58 | // e.g. ["input:", "cursor:"]: drop every override under those sections, then reload Hyprland's config so the |
| 59 | // repo values come back. The reload waits for hypr.lua to be written (luaFile.onSaved): reloading right after |
| 60 | // setText() raced the async write and Hyprland re-read the old overrides. |
| 61 | function resetAll(prefixes) { |
| 62 | var o = Object.assign({}, overrides), n = 0 |
| 63 | for (var k in o) |
| 64 | for (var i = 0; i < prefixes.length; i++) |
| 65 | if (k.indexOf(prefixes[i]) === 0) { delete o[k]; n++; break } |
| 66 | pending = {} // a slider drag still waiting to be applied would undo the reset |
| 67 | applyTimer.stop() |
| 68 | saveTimer.stop() |
| 69 | if (n === 0) { message = ""; refresh(); return } // nothing overridden: already the config values |
| 70 | overrides = o |
| 71 | reloadAfterSave = true |
| 72 | save() |
| 73 | } |
| 74 | property bool reloadAfterSave: false |
| 75 | function refresh() { |
| 76 | readProc.command = ["sh", "-c", 'for o; do hyprctl -j getoption "$o" | tr -d "\\n"; echo; done', "sh"] |
| 77 | .concat(Object.keys(types)) |
| 78 | readProc.running = true |
| 79 | } |
| 80 | |
| 81 | // ---- lua generation: keys only ever come from `types`, values are typed + escaped ---- |
| 82 | function luaValue(opt, v) { |
| 83 | var t = types[opt] |
| 84 | if (t === "bool") return v ? "true" : "false" |
| 85 | if (t === "int") return String(Math.round(Number(v)) || 0) |
| 86 | if (t === "float") { var n = Number(v); return String(isFinite(n) ? n : 0) } |
| 87 | return '"' + String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n]/g, " ") + '"' |
| 88 | } |
| 89 | function luaTable(map) { // {"a:b:c": v} -> "{ a = { b = { c = v } } }" |
| 90 | var tree = {} |
| 91 | for (var opt in map) { |
| 92 | if (!types.hasOwnProperty(opt)) continue |
| 93 | var parts = opt.split(":"), node = tree |
| 94 | for (var i = 0; i < parts.length - 1; i++) { |
| 95 | if (typeof node[parts[i]] !== "object") node[parts[i]] = {} |
| 96 | node = node[parts[i]] |
| 97 | } |
| 98 | node[parts[parts.length - 1]] = { lua: luaValue(opt, map[opt]) } |
| 99 | } |
| 100 | function emit(n, ind) { |
| 101 | var keys = Object.keys(n).sort(), out = [] |
| 102 | for (var j = 0; j < keys.length; j++) { |
| 103 | var x = n[keys[j]] |
| 104 | out.push(ind + " " + keys[j] + " = " + (x.hasOwnProperty("lua") ? x.lua : emit(x, ind + " "))) |
| 105 | } |
| 106 | return out.length ? "{\n" + out.join(",\n") + "\n" + ind + "}" : "{}" |
| 107 | } |
| 108 | return emit(tree, "") |
| 109 | } |
| 110 | |
| 111 | function save() { |
| 112 | jsonFile.setText(JSON.stringify(overrides, null, 2) + "\n") |
| 113 | luaFile.setText("-- generated by aurora's settings app (HyprConf.qml); edit there, not here\n" |
| 114 | + "return " + luaTable(overrides) + "\n") |
| 115 | } |
| 116 | |
| 117 | property var pending: ({}) |
| 118 | Timer { |
| 119 | id: applyTimer |
| 120 | interval: 60 |
| 121 | onTriggered: { |
| 122 | var code = "hl.config(" + root.luaTable(root.pending) + ")" |
| 123 | root.pending = {} |
| 124 | applyProc.command = ["hyprctl", "eval", code] |
| 125 | applyProc.running = true |
| 126 | } |
| 127 | } |
| 128 | Timer { id: saveTimer; interval: 400; onTriggered: root.save() } |
| 129 | |
| 130 | Process { |
| 131 | id: applyProc |
| 132 | stdout: StdioCollector { |
| 133 | onStreamFinished: root.message = text.trim() === "ok" || text.trim() === "" ? "" : text.trim() |
| 134 | } |
| 135 | } |
| 136 | Process { |
| 137 | id: reloadProc |
| 138 | command: ["hyprctl", "reload", "config-only"] |
| 139 | onExited: root.refresh() |
| 140 | } |
| 141 | Process { |
| 142 | id: readProc |
| 143 | stdout: StdioCollector { |
| 144 | onStreamFinished: { |
| 145 | var c = {}, lines = text.split("\n") |
| 146 | for (var i = 0; i < lines.length; i++) { |
| 147 | if (lines[i].trim() === "") continue |
| 148 | try { |
| 149 | var j = JSON.parse(lines[i]), v |
| 150 | if (j.hasOwnProperty("css")) v = parseInt(String(j.css).split(" ")[0]) // gaps: "2 2 2 2" |
| 151 | else if (j.hasOwnProperty("int")) v = j.int |
| 152 | else if (j.hasOwnProperty("float")) v = j.float |
| 153 | else if (j.hasOwnProperty("bool")) v = j.bool |
| 154 | else if (j.hasOwnProperty("str")) v = j.str === "[[EMPTY]]" ? "" : j.str // unset string option |
| 155 | if (v !== undefined) c[j.option] = root.types[j.option] === "bool" ? !!v : v |
| 156 | } catch (e) {} |
| 157 | } |
| 158 | root.values = c |
| 159 | root.loaded = true |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | FileView { |
| 165 | id: jsonFile |
| 166 | path: root.dir + "/hypr.json" |
| 167 | blockLoading: true |
| 168 | printErrors: false // no file until the first change |
| 169 | onLoaded: { |
| 170 | try { root.overrides = JSON.parse(text()) || {} } catch (e) { root.overrides = {} } |
| 171 | } |
| 172 | } |
| 173 | FileView { // write-only |
| 174 | id: luaFile |
| 175 | path: root.dir + "/hypr.lua" |
| 176 | preload: false |
| 177 | printErrors: false |
| 178 | onSaved: if (root.reloadAfterSave) { root.reloadAfterSave = false; reloadProc.running = true } |
| 179 | onSaveFailed: err => { root.reloadAfterSave = false; root.message = "could not write hypr.lua (error " + err + ")" } |
| 180 | } |
| 181 | |
| 182 | Component.onCompleted: refresh() |
| 183 | } |