A simple yet powerful UI overlay made for Wayland WMs built with Quickshell
wayland
qs
linux
ui
ux
1import QtQuick
2import Quickshell.Io
3import Quickshell
4
5QtObject {
6 id: root
7
8 property string cfgPath: "$HOME/.config/quickshell/m4.shell/config.json"
9 property string ctlPath: "$HOME/.config/quickshell/m4.shell/scripts/configctl.sh"
10
11 property var data: ({})
12 property bool loaded: false
13
14 property bool hydrating: false
15
16 property Timer saveTimer: Timer {
17 interval: 150
18 repeat: false
19 onTriggered: root.flush()
20 }
21
22 function scheduleSave() {
23 if (!loaded || hydrating) return
24 saveTimer.restart()
25 }
26
27 function quoteForShell(s) {
28 return "'" + String(s).replace(/'/g, "'\\''") + "'"
29 }
30
31 property Process loadProc: Process {
32 command: ["sh", "-lc", root.ctlPath + " dump"]
33 stdout: StdioCollector {
34 waitForEnd: true
35 onStreamFinished: {
36 const txt = (this.text || "").trim()
37 if (!txt) return
38 try {
39 const obj = JSON.parse(txt)
40 root.applyLoaded(obj)
41 } catch (e) {
42 console.error("ConfigService: JSON parse failed:", e)
43 }
44 }
45 }
46 stderr: StdioCollector {
47 waitForEnd: true
48 onStreamFinished: {
49 const err = (this.text || "").trim()
50 if (err) console.error("ConfigService: load stderr:", err)
51 }
52 }
53 }
54
55 function reload() {
56 loaded = false
57 hydrating = true
58 loadProc.exec(loadProc.command)
59 }
60
61 property Process saveProc: Process {
62 stderr: StdioCollector {
63 waitForEnd: true
64 onStreamFinished: {
65 const err = (this.text || "").trim()
66 if (err) console.error("ConfigService: save stderr:", err)
67 }
68 }
69 }
70
71 function flush() {
72 if (!loaded || hydrating) return
73 const payload = JSON.stringify(root.data)
74
75 saveProc.command = ["sh", "-lc", root.ctlPath + " write " + quoteForShell(payload)]
76 saveProc.exec(saveProc.command)
77 }
78
79 property Process resetProc: Process {
80 stderr: StdioCollector {
81 waitForEnd: true
82 onStreamFinished: {
83 const err = (this.text || "").trim()
84 if (err) console.error("ConfigService: reset stderr:", err)
85 }
86 }
87 onExited: (code) => {
88 if (code === 0) root.reload()
89 }
90 }
91
92 function restoreDefaults() {
93 resetProc.command = ["sh", "-lc", root.ctlPath + " reset"]
94 resetProc.exec(resetProc.command)
95 }
96
97 function get(pathArr, fallback) {
98 var cur = root.data
99 for (var i = 0; i < pathArr.length; i++) {
100 if (!cur || cur[pathArr[i]] === undefined) return fallback
101 cur = cur[pathArr[i]]
102 }
103 return cur
104 }
105
106 function setPath(pathArr, value) {
107 if (!root.data || typeof root.data !== "object") root.data = ({})
108 var cur = root.data
109 for (var i = 0; i < pathArr.length - 1; i++) {
110 const k = pathArr[i]
111 if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object")
112 cur[k] = {}
113 cur = cur[k]
114 }
115 cur[pathArr[pathArr.length - 1]] = value
116 root.data = root.data
117 scheduleSave()
118 }
119
120 function applyLoaded(obj) {
121 hydrating = true
122 root.data = (obj && typeof obj === "object") ? obj : ({})
123
124 bg = String(get(["appearance","bg"], "#0B0B0B"))
125 bg2 = String(get(["appearance","bg2"], "#1A1A1A"))
126 fg = String(get(["appearance","fg"], "#E6E6E6"))
127 text = String(get(["appearance","text"], "#E6E6E6"))
128 muted = String(get(["appearance","muted"], "#A8A8A8"))
129 accent = String(get(["appearance","accent"], "#B80000"))
130 borderColor = String(get(["appearance","borderColor"], "#2A2A2A"))
131 opacity = Number(get(["appearance","opacity"], 1.0))
132
133 barHeight = parseInt(get(["appearance","barHeight"], 35), 10)
134 pad = parseInt(get(["appearance","pad"], 20), 10)
135 fontSize = parseInt(get(["appearance","fontSize"], 12), 10)
136 radius = parseInt(get(["appearance","radius"], 12), 10)
137 animMs = parseInt(get(["appearance","animMs"], 40), 10)
138
139 edge = String(get(["sidebar","edge"], "left")).trim().toLowerCase()
140 edgeWidth = parseInt(get(["sidebar","edgeWidth"], 8), 10)
141 edgeCornerRadius = parseInt(get(["sidebar","edgeCornerRadius"], 20), 10)
142 sidebarWidth = parseInt(get(["sidebar","sidebarWidth"], 300), 10)
143 hoverCloseDelayMs = parseInt(get(["sidebar","hoverCloseDelayMs"], 300), 10)
144
145 const ebs = get(["sidebar","edgeByScreen"], ({}))
146 edgeByScreen = (ebs && typeof ebs === "object") ? ebs : ({})
147
148 const lm = get(["bar","leftModules"], [])
149 const cm = get(["bar","centerModules"], [])
150 const rm = get(["bar","rightModules"], [])
151
152 leftModules = Array.isArray(lm) ? lm : []
153 centerModules = Array.isArray(cm) ? cm : []
154 rightModules = Array.isArray(rm) ? rm : []
155
156 hydrating = false
157 loaded = true
158 }
159
160 property string bg: "#0B0B0B"
161 property string bg2: "#1A1A1A"
162 property string fg: "#E6E6E6"
163 property string text: "#E6E6E6"
164 property string muted: "#A8A8A8"
165 property string accent: "#B80000"
166 property string borderColor: "#2A2A2A"
167 property real opacity: 1.0
168
169 property int barHeight: 35
170 property int pad: 20
171 property int fontSize: 12
172 property int radius: 12
173 property int animMs: 40
174
175 onBgChanged: if (loaded && !hydrating) setPath(["appearance","bg"], String(bg))
176 onBg2Changed: if (loaded && !hydrating) setPath(["appearance","bg2"], String(bg2))
177 onFgChanged: if (loaded && !hydrating) setPath(["appearance","fg"], String(fg))
178 onTextChanged: if (loaded && !hydrating) setPath(["appearance","text"], String(text))
179 onMutedChanged: if (loaded && !hydrating) setPath(["appearance","muted"], String(muted))
180 onAccentChanged: if (loaded && !hydrating) setPath(["appearance","accent"], String(accent))
181 onBorderColorChanged: if (loaded && !hydrating) setPath(["appearance","borderColor"], String(borderColor))
182 onOpacityChanged: if (loaded && !hydrating) setPath(["appearance","opacity"], opacity)
183
184 onBarHeightChanged: if (loaded && !hydrating) setPath(["appearance","barHeight"], barHeight)
185 onPadChanged: if (loaded && !hydrating) setPath(["appearance","pad"], pad)
186 onFontSizeChanged: if (loaded && !hydrating) setPath(["appearance","fontSize"], fontSize)
187 onRadiusChanged: if (loaded && !hydrating) setPath(["appearance","radius"], radius)
188 onAnimMsChanged: if (loaded && !hydrating) setPath(["appearance","animMs"], animMs)
189
190 property string edge: "left"
191 property int edgeWidth: 8
192 property int edgeCornerRadius: 20
193 property int sidebarWidth: 300
194 property int hoverCloseDelayMs: 300
195 property var edgeByScreen: ({})
196
197 onEdgeChanged: if (loaded && !hydrating) setPath(["sidebar","edge"], String(edge).trim().toLowerCase())
198 onEdgeWidthChanged: if (loaded && !hydrating) setPath(["sidebar","edgeWidth"], edgeWidth)
199 onEdgeCornerRadiusChanged: if (loaded && !hydrating) setPath(["sidebar","edgeCornerRadius"], edgeCornerRadius)
200 onSidebarWidthChanged: if (loaded && !hydrating) setPath(["sidebar","sidebarWidth"], sidebarWidth)
201 onHoverCloseDelayMsChanged: if (loaded && !hydrating) setPath(["sidebar","hoverCloseDelayMs"], hoverCloseDelayMs)
202 onEdgeByScreenChanged: if (loaded && !hydrating) setPath(["sidebar","edgeByScreen"], edgeByScreen)
203
204 property var leftModules: []
205 property var centerModules: []
206 property var rightModules: []
207
208 onLeftModulesChanged: if (loaded && !hydrating) setPath(["bar","leftModules"], leftModules)
209 onCenterModulesChanged: if (loaded && !hydrating) setPath(["bar","centerModules"], centerModules)
210 onRightModulesChanged: if (loaded && !hydrating) setPath(["bar","rightModules"], rightModules)
211
212 function setBarEnabled(section, k, on) {
213 const prop = section + "Modules"
214 const arr = (root[prop] || [])
215 root[prop] = arr.map(e => (e.key === k ? ({ key: e.key, enabled: !!on }) : e))
216 }
217
218 Component.onCompleted: reload()
219}