tonybtw.com

tonybtw.com

https://git.tonybtw.com/tonybtw.com.git git://git.tonybtw.com/tonybtw.com.git
14,127 bytes raw
1
#+TITLE: Quickshell Tutorial - Build Your Own Bar
2
#+AUTHOR: Tony, btw
3
#+date: 2025-12-03
4
#+HUGO_TITLE: Quickshell Tutorial - Build Your Own Bar
5
#+HUGO_FRONT_MATTER_FORMAT: yaml
6
#+HUGO_CUSTOM_FRONT_MATTER: :image "/img/quickshell.png" :showTableOfContents true
7
#+HUGO_BASE_DIR: ~/repos/tonybtw.com
8
#+HUGO_SECTION: tutorial/quickshell
9
#+EXPORT_FILE_NAME: index
10
#+OPTIONS: toc:nil broken-links:mark
11
#+HUGO_AUTO_SET_HEADLINE_SECTION: nil
12
#+DESCRIPTION: This is a quick and painless tutorial on how to build a custom status bar using Quickshell, a powerful Qt/QML based shell framework for Wayland.
13
14
* Intro
15
16
What's up guys, my name is Tony, and today I'm gonna give you a quick and painless introduction to Quickshell.
17
18
Quickshell is a full shell framework built on Qt/QML. You can build pretty much any desktop widget you can imagine with it - bars, dashboards, wallpaper managers, screen lock widgets, and more.
19
20
We're going to do a basic overview of how to create a bar using Quickshell today. In future tutorials I'll cover creating a wallpaper manager, building a dashboard overlay, and making a screen lock widget. But for now, let's start with the fundamentals by building a functional status bar step by step.
21
22
* Install Quickshell
23
24
Alright so I'm on NixOS today, but this is going to work on Arch, Gentoo, and other distributions. I'll leave install instructions for all of those below.
25
26
[[https://quickshell.org/docs/v0.2.1/types/][Quickshell Documentation]]
27
28
*** NixOS
29
#+begin_src nix
30
{
31
  environment.systemPackages = with pkgs; [
32
    quickshell
33
  ];
34
}
35
#+end_src
36
37
*** Arch Linux
38
#+begin_src sh
39
yay -S quickshell-git
40
#+end_src
41
42
*** Gentoo
43
For Gentoo, you'll need to compile from source:
44
45
#+begin_src sh
46
git clone https://github.com/outfoxxed/quickshell
47
cd quickshell
48
# Follow the build instructions in their README
49
#+end_src
50
51
* Running Examples
52
53
You can run any of these examples with:
54
55
#+begin_src sh
56
qs -p ~/.config/testshell/01-hello.qml
57
#+end_src
58
59
Just swap out the filename as we go through each one.
60
61
* 01 - Hello World
62
63
Quickshell has excellent documentation, and we're going to be following that today. Let's start with the absolute basics - just getting something on screen.
64
65
#+begin_src qml
66
import Quickshell
67
import QtQuick
68
69
FloatingWindow {
70
    visible: true
71
    width: 200
72
    height: 100
73
74
    Text {
75
        anchors.centerIn: parent
76
        text: "Hello, Quickshell!"
77
        color: "#0db9d7"
78
        font.pixelSize: 18
79
    }
80
}
81
#+end_src
82
83
So what's going on here?
84
85
Every Quickshell config starts with imports. We're pulling in =Quickshell= for the core stuff and =QtQuick= for basic UI elements like =Text= and =Rectangle=.
86
87
We're using =FloatingWindow= as our root element. This is just a regular floating window - it doesn't dock to any edges or reserve any screen space. We're setting it to 200x100 pixels and making it visible.
88
89
The =Text= element is pretty self-explanatory. The =anchors.centerIn: parent= bit is QML's layout system - it just centers the text inside its parent container.
90
91
* 02 - Empty Bar
92
93
Alright, let's turn this into an actual bar that docks to the top of your screen.
94
95
#+begin_src qml
96
import Quickshell
97
import Quickshell.Wayland
98
import QtQuick
99
100
PanelWindow {
101
    anchors.top: true
102
    anchors.left: true
103
    anchors.right: true
104
    implicitHeight: 30
105
    color: "#1a1b26"
106
107
    Text {
108
        anchors.centerIn: parent
109
        text: "My First Bar"
110
        color: "#a9b1d6"
111
        font.pixelSize: 14
112
    }
113
}
114
#+end_src
115
116
The big change here is we're using =PanelWindow= as our root element instead of =FloatingWindow=. This is a Wayland-specific thing (hence the new import), and it lets us dock the window to screen edges.
117
118
Setting =anchors.top=, =anchors.left=, and =anchors.right= to =true= tells it to stick to the top edge and span the full width. The =implicitHeight: 30= gives us a 30 pixel tall bar.
119
120
Unlike a floating window, a =PanelWindow= actually reserves space - your other windows won't overlap with it.
121
122
* 03 - Workspaces
123
124
So we are on Hyprland today, lets add quickshell to our Hyprland config.
125
126
Now let's add some actual functionality - workspace indicators that show which workspace you're on and let you click to switch.
127
128
#+begin_src qml
129
import Quickshell
130
import Quickshell.Wayland
131
import Quickshell.Hyprland
132
import QtQuick
133
import QtQuick.Layouts
134
135
PanelWindow {
136
    anchors.top: true
137
    anchors.left: true
138
    anchors.right: true
139
    implicitHeight: 30
140
    color: "#1a1b26"
141
142
    RowLayout {
143
        anchors.fill: parent
144
        anchors.margins: 8
145
146
        Repeater {
147
            model: 9
148
149
            Text {
150
                property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1)
151
                property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1)
152
                text: index + 1
153
                color: isActive ? "#0db9d7" : (ws ? "#7aa2f7" : "#444b6a")
154
                font { pixelSize: 14; bold: true }
155
156
                MouseArea {
157
                    anchors.fill: parent
158
                    onClicked: Hyprland.dispatch("workspace " + (index + 1))
159
                }
160
            }
161
        }
162
163
        Item { Layout.fillWidth: true }
164
    }
165
}
166
#+end_src
167
168
Okay, there's a lot more going on here. Let me break it down.
169
170
We're importing =Quickshell.Hyprland= which gives us access to Hyprland's IPC.
171
172
=QtQuick.Layouts= gives us =RowLayout=, which arranges its children horizontally. Way easier than manually positioning everything.
173
174
The =Repeater= is super useful - it takes a model (in this case, just the number 9) and creates that many copies of whatever's inside it. Each copy gets an =index= variable (0-8).
175
176
For each workspace number, we're looking up the actual workspace from the window manager with =Hyprland.workspaces.values.find()=. This gives us live data - when workspaces change, the bar updates automatically. We also check if it's the active workspace using =Hyprland.focusedWorkspace=.
177
178
The color logic is straightforward: cyan if it's the active workspace, blue if it exists but isn't active, and muted gray if there's no windows on that workspace.
179
180
The =MouseArea= makes the whole thing clickable, and =Hyprland.dispatch()= sends commands to Hyprland. So clicking on "3" runs =workspace 3=.
181
182
That =Item { Layout.fillWidth: true }= at the end is just a spacer - it pushes everything to the left.
183
184
* 04 - System Stats
185
186
Now let's add some system stats. This is where things get interesting because we need to run shell commands and parse their output.
187
188
** Theme Properties
189
190
Instead of hardcoding colors everywhere, we define them once on the PanelWindow. Now you can reference =root.colBg= anywhere in your config, and if you want to change your color scheme, you only have to do it in one place.
191
192
#+begin_src qml
193
PanelWindow {
194
    id: root
195
    property color colBg: "#1a1b26"
196
    property color colFg: "#a9b1d6"
197
    property color colMuted: "#444b6a"
198
    property color colCyan: "#0db9d7"
199
    property color colBlue: "#7aa2f7"
200
    property color colYellow: "#e0af68"
201
    property string fontFamily: "JetBrainsMono Nerd Font"
202
    property int fontSize: 14
203
}
204
#+end_src
205
206
** Running Shell Commands
207
208
This is how you run external commands and capture their output. We import =Quickshell.Io= which gives us the =Process= type.
209
210
#+begin_src qml
211
Process {
212
    id: cpuProc
213
    command: ["sh", "-c", "head -1 /proc/stat"]
214
    stdout: SplitParser {
215
        onRead: data => {
216
            if (!data) return
217
            var p = data.trim().split(/\s+/)
218
            var idle = parseInt(p[4]) + parseInt(p[5])
219
            var total = p.slice(1, 8).reduce((a, b) => a + parseInt(b), 0)
220
            if (lastCpuTotal > 0) {
221
                cpuUsage = Math.round(100 * (1 - (idle - lastCpuIdle) / (total - lastCpuTotal)))
222
            }
223
            lastCpuTotal = total
224
            lastCpuIdle = idle
225
        }
226
    }
227
    Component.onCompleted: running = true
228
}
229
#+end_src
230
231
The =command= is an array - first element is the program, rest are arguments. The =SplitParser= attached to =stdout= calls =onRead= for each line of output. Setting =running = true= triggers the process to run.
232
233
** Timers
234
235
To update the CPU usage periodically, we use a =Timer=. This one fires every 2 seconds and re-runs the CPU process.
236
237
#+begin_src qml
238
Timer {
239
    interval: 2000        // Every 2 seconds
240
    running: true         // Start immediately
241
    repeat: true          // Keep going forever
242
    onTriggered: cpuProc.running = true
243
}
244
#+end_src
245
246
* 05 - Adding Widgets
247
248
Let's expand our bar with a clock and memory usage. This builds on the same patterns - more =Process= calls and =Timer= elements.
249
250
** Memory Widget
251
252
#+begin_src qml
253
// Add to your system data properties
254
property int memUsage: 0
255
256
// Memory process
257
Process {
258
    id: memProc
259
    command: ["sh", "-c", "free | grep Mem"]
260
    stdout: SplitParser {
261
        onRead: data => {
262
            if (!data) return
263
            var parts = data.trim().split(/\s+/)
264
            var total = parseInt(parts[1]) || 1
265
            var used = parseInt(parts[2]) || 0
266
            memUsage = Math.round(100 * used / total)
267
        }
268
    }
269
    Component.onCompleted: running = true
270
}
271
272
// Update your timer to run both processes
273
Timer {
274
    interval: 2000
275
    running: true
276
    repeat: true
277
    onTriggered: {
278
        cpuProc.running = true
279
        memProc.running = true
280
    }
281
}
282
#+end_src
283
284
** Clock
285
286
The clock is just a =Text= element with its own timer. Every second it updates the text with the current time.
287
288
#+begin_src qml
289
Text {
290
    id: clock
291
    text: Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm")
292
293
    Timer {
294
        interval: 1000
295
        running: true
296
        repeat: true
297
        onTriggered: clock.text = Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm")
298
    }
299
}
300
#+end_src
301
302
** Adding Dividers
303
304
To visually separate widgets, you can use simple =Rectangle= elements:
305
306
#+begin_src qml
307
Rectangle { width: 1; height: 16; color: root.colMuted }
308
#+end_src
309
310
* Complete Bar Example
311
312
Here's the final bar with workspaces, CPU, memory, and a clock all together:
313
314
#+begin_src qml
315
import Quickshell
316
import Quickshell.Wayland
317
import Quickshell.Io
318
import Quickshell.Hyprland
319
import QtQuick
320
import QtQuick.Layouts
321
322
PanelWindow {
323
    id: root
324
325
    // Theme
326
    property color colBg: "#1a1b26"
327
    property color colFg: "#a9b1d6"
328
    property color colMuted: "#444b6a"
329
    property color colCyan: "#0db9d7"
330
    property color colBlue: "#7aa2f7"
331
    property color colYellow: "#e0af68"
332
    property string fontFamily: "JetBrainsMono Nerd Font"
333
    property int fontSize: 14
334
335
    // System data
336
    property int cpuUsage: 0
337
    property int memUsage: 0
338
    property var lastCpuIdle: 0
339
    property var lastCpuTotal: 0
340
341
    // Processes and timers here...
342
343
    anchors.top: true
344
    anchors.left: true
345
    anchors.right: true
346
    implicitHeight: 30
347
    color: root.colBg
348
349
    RowLayout {
350
        anchors.fill: parent
351
        anchors.margins: 8
352
        spacing: 8
353
354
        // Workspaces
355
        Repeater {
356
            model: 9
357
            Text {
358
                property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1)
359
                property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1)
360
                text: index + 1
361
                color: isActive ? root.colCyan : (ws ? root.colBlue : root.colMuted)
362
                font { family: root.fontFamily; pixelSize: root.fontSize; bold: true }
363
                MouseArea {
364
                    anchors.fill: parent
365
                    onClicked: Hyprland.dispatch("workspace " + (index + 1))
366
                }
367
            }
368
        }
369
370
        Item { Layout.fillWidth: true }
371
372
        // CPU
373
        Text {
374
            text: "CPU: " + cpuUsage + "%"
375
            color: root.colYellow
376
            font { family: root.fontFamily; pixelSize: root.fontSize; bold: true }
377
        }
378
379
        Rectangle { width: 1; height: 16; color: root.colMuted }
380
381
        // Memory
382
        Text {
383
            text: "Mem: " + memUsage + "%"
384
            color: root.colCyan
385
            font { family: root.fontFamily; pixelSize: root.fontSize; bold: true }
386
        }
387
388
        Rectangle { width: 1; height: 16; color: root.colMuted }
389
390
        // Clock
391
        Text {
392
            id: clock
393
            color: root.colBlue
394
            font { family: root.fontFamily; pixelSize: root.fontSize; bold: true }
395
            text: Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm")
396
            Timer {
397
                interval: 1000
398
                running: true
399
                repeat: true
400
                onTriggered: clock.text = Qt.formatDateTime(new Date(), "ddd, MMM dd - HH:mm")
401
            }
402
        }
403
    }
404
}
405
#+end_src
406
407
* Key Concepts Summary
408
409
| Concept       | Description                                           |
410
|---------------+-------------------------------------------------------|
411
| FloatingWindow | A regular floating window, doesn't dock              |
412
| PanelWindow   | Docks to screen edges, reserves space                 |
413
| RowLayout     | Arranges children horizontally                        |
414
| Repeater      | Creates multiple copies of a component                |
415
| Process       | Runs shell commands and captures output               |
416
| Timer         | Triggers actions at intervals                         |
417
| MouseArea     | Makes elements clickable                              |
418
| anchors       | QML's layout system for positioning                   |
419
| property      | Declare custom variables on components                |
420
421
* Next Steps
422
423
That's the core of it. Quickshell can do way more than just bars though - you can build:
424
425
- Wallpaper managers with smooth transitions
426
- Dashboard overlays with system stats
427
- Screen lock widgets
428
- Notification centers
429
- Application launchers
430
431
Check out the Quickshell documentation for more advanced features and the full API reference.
432
433
* Final Thoughts
434
435
Thanks so much for checking out this tutorial. If you got value from it, and you want to find more tutorials like this, check out
436
my youtube channel here: [[https://youtube.com/@tony-btw][YouTube]], or my website here: [[https://www.tonybtw.com][tony,btw]]
437
438
You can support me here: [[https://ko-fi.com/tonybtw][kofi]]