TopBar

19 Feb 2026

Desktop environment with Quickshell

#Eww

It all started with waybar on Wayland a few years ago. Then a brief barless era while I was enjoying my new wallpapers. Then I switch to eww, and this was pretty fun. "Lisping" GTK widgets, I got pretty close to what I wanted to achieve. A lot of things worked out of the box on FreeBSD, including system stats and network. I still had to write a few scripts for things like system tray, volume controls, etc. But nothing special, mostly wrappers.

#Quickshell

For quite some time eww was enough for me. But the development slowed down, and GTK3 became a drag. I started thinking about the replacement and discovered quickshell.

Well, this is another level. Qt to drive the whole desktop environment. And with simple QML syntax. Of course it could be pretty something sometimes (or more often), but definitely a breeze after XML. The current release version is 0.2.1, but I need to compile my own build to get the network state, audio controls, and system tray icons.

#Patches

I still need to work on these, though everything is pretty stable. No polling anywhere on this level. Some widgets in the bar do poll, but there is an option to turn them off (useful in low-power mode). Patches can be cherry-picked on top of the fbsd-sound branch of my quickshell fork with no conflicts (so far).

#Installation

TopBar is structured as a git submodule, so it could be easily injected into any root file:

//@ pragma UseQApplication

import Quickshell
import QtQuick

// imports the topbar directory (git submodule)
import qs.topbar

ShellRoot {
    // TopBar modules
    TopBar {}
    Wallpaper {}

    // more of a future-proof measure
    Connections {
        target: Quickshell

        function onLastWindowClosed() {
            Qt.quit();
        }
    }

    Component.onCompleted: {
        // some components do not work as expected without a full cycle
        Quickshell.watchFiles = false;
    }
}

To start, just add a submodule to your current configuration repository (it must mirror $HOME):

git submodule add https://github.com/charlesrocket/topbar .config/quickshell/topbar

Or manually copy it inside of the Quickshell's directory (~/.config/quickshell).

I noticed that live refresh (watch) does not work for some widgets as expected, so I just turned it off to save the memory.

#Configuration

The Settings.qml file is used to override default values in the main config file (Config.qml). It should be placed next to the main shell.qml file.

Settings.qml:

pragma Singleton

import QtQuick
import Quickshell

Singleton {
    readonly property var general: QtObject {
        readonly property string locale: "en_US"
        readonly property string font: "Hack Nerd Font"
        readonly property int fontSize: 14
        readonly property int radius: 8
        readonly property int borderWidth: 0
        readonly property int animDuration: 250
        readonly property string wallpaper: "~/Pictures/hardcoding/zaki-aby-fisheye-top-0-064.jpg"
    }

    readonly property var bar: QtObject {
        readonly property int height: 30
        readonly property int padding: 8
    }

    readonly property var desktop: QtObject {
        readonly property bool launcher: true
        readonly property bool osd: true
    }

    property var dashboard: QtObject {
        property var player: QtObject {
            property bool queueButtons: true
        }
    }

    readonly property var widgets: QtObject {
        readonly property bool workspaces: true
        readonly property bool battery: true
        readonly property bool network: true
        readonly property bool bluetooth: true
        readonly property bool audio: true
        readonly property bool title: true
        readonly property bool language: true
        readonly property bool weather: true
        readonly property bool clock: true
        readonly property bool stats: true
        readonly property bool tray: true
    }

    readonly property var session: QtObject {
        readonly property var commands: QtObject {
            readonly property string lock: "quickshell ipc call topbar lock"
            readonly property string logout: "hyprctl dispatch exit | pkill mango"
            readonly property string suspend: "zzz"
            readonly property string hibernate: "acpiconf -s 4"
            readonly property string shutdown: "shutdown -p now"
            readonly property string reboot: "shutdown -r now"
        }
    }
}

I am working on a settings/config panel, finished all the models, and am now polishing the visuals.

#Internal systems

It is not just a bar anymore. TopBar can handle pretty much everything in the desktop environment, including the application launcher, lock screen, wallpaper, and whatever the desktop might need.

Right now there are two independent modules available: Wallpaper and TopBar. Both are configurable via settings.

#The surface

Because this is Wayland, we cannot just slap a context menu onto the bar. The main window that holds the bar will clip everything that spills outside of its borders. To work around this, I used to rely on horizontal sliders to keep the UI elements within the bar's boundaries. This works very well with eww and quickshell, but a busy bar has a very limited amount of real estate to facilitate all the elements in these sliders, and reaching neighboring elements sometimes might be a bit awkward. With Quickshell, one could deploy the main window (PanelWindow) that spans across the whole desktop surface and set the bar as a small rectangle on the top (or any other location). The issue with this approach is that there is no way to interact with any other desktop element due to the bar's window covering the whole surface. To resolve this, we can use Region with the window's mask property:

PanelWindow {
    id: root

    // simple single screen option here
    property var screen: Quickshell.screens[0]

    anchors {
        top: true
        left: true
        right: true
    }

    mask: itemsRegions
    color: "transparent"
    implicitHeight: screen.height
    // this reserves the space for the bar
    exclusiveZone: bar.visible ? bar.height + Config.bar.padding : 0

    Rectangle {
        id: bar
        y: 0
        anchors.horizontalCenter: parent.horizontalCenter
        implicitWidth: root.screen.width
        implicitHeight: Config.bar.height
        border.width: Config.general.borderWidth
        border.color: Config.colors.border
        height: Config.bar.height
        color: Config.colors.bg
        radius: Config.general.cornerRadius

        // this offloads all bar components when deactivated
        // useful in some cases (full screen/game mode, etc)
        Loader {
            active: States.barEnabled
            visible: States.barEnabled
            Layout.alignment: Qt.AlignVCenter
            anchors.fill: parent
            anchors.leftMargin: 13
            anchors.rightMargin: 12

            sourceComponent: RowLayout {
                Bar.Left {
                    Layout.fillWidth: true
                    Layout.minimumWidth: 0
                    Layout.alignment: Qt.AlignLeft
                }

                Bar.Center {
                    Layout.fillWidth: false
                    Layout.preferredWidth: 400
                    Layout.alignment: Qt.AlignHCenter
                }

                Bar.Right {
                    Layout.fillWidth: true
                    Layout.minimumWidth: 0
                    Layout.alignment: Qt.AlignRight
                }
            }
        }
    }
    
    // iterates over all window components and
    // creates their regions
    Variants {
        id: regions
        model: root.contentItem.children
    
        delegate: Region {
            required property Item modelData
            item: modelData
        }
    }

    // regions for all window components
    Region {
        id: itemsRegions
        regions: regions.instances
    }
}

This deploys a transparent window that covers the whole desktop (via anchors). The main bar is a rectangle with some elements on top of that window (y: 0). itemsRegions masks all of the bar's elements, allowing clicks outside of their regions to pass through. Now we can interact with the bar and any other window on the desktop surface.

#Shapes

After converting some sliders into dropdown menus, I wanted to go further and blend these menus with the bar. QML has a neat Shape class that allows drawing 2D objects using the surface's coordinates. The concept is pretty simple: define a ShapePath and all the lines that it must include. So if I want a straight line from left to right, all I need is PathLine { x: 50; y: 0 }. This will draw a 50px-long line from the start coordinates to the right. x: -50 will do the same but in the opposite direction. For curved lines we can use PathArc which also takes x/y radius in addition to the coordinates.

Shape {
    preferredRendererType: Shape.CurveRenderer
    anchors.fill: parent

    ShapePath {
        strokeColor: Config.colors.border
        strokeWidth: Config.general.borderWidth > 0 ? Config.general.borderWidth : -1
        fillColor: States.ecoMode ? Config.colors.bge : Config.colors.bg

        // relative to 0:0
        // this would be the top left corner of the container

        // starting from the most-left position
        // which is the start of a ramp (-14)
        startX: -(Config.general.cornerRadius * 2)
        startY: 0

        // drawing the arc at the even radius
        PathArc {
            x: 0
            y: Config.general.cornerRadius * 2
            radiusX: Config.general.cornerRadius * 2
            radiusY: Config.general.cornerRadius * 2
        }

        // now we have reached the container
        // from here we just draw a regular rectangle
        // where the container borders would be
        PathLine {
            x: 0
            y: dropdown.height - Config.general.cornerRadius
        }

        // first round corner at the bottom
        PathArc {
            x: Config.general.cornerRadius
            y: dropdown.height
            direction: PathArc.Counterclockwise
            radiusX: Config.general.cornerRadius
            radiusY: Config.general.cornerRadius
        }

        // straight line at the bottom
        PathLine {
            x: dropdown.width - Config.general.cornerRadius
            y: dropdown.height
        }

        // another round corner
        PathArc {
            x: dropdown.width
            y: dropdown.height - Config.general.cornerRadius
            direction: PathArc.Counterclockwise
            radiusX: Config.general.cornerRadius
            radiusY: Config.general.cornerRadius
        }

        // all the way up to where we are going to
        // start drawing another ramp
        PathLine {
            x: dropdown.width
            y: Config.general.cornerRadius * 2
        }

        // another ramp completes the rectangle
        PathArc {
            x: dropdown.width + Config.general.cornerRadius * 2
            y: 0
            radiusX: Config.general.cornerRadius * 2
            radiusY: Config.general.cornerRadius * 2
        }
    }
}

This would produce a rectangle with curved "ramps" on its top sides. Since the ramps must be outside the main container, the start position is negative (-Config.general.cornerRadius * 2, and the value of cornerRadius here is 8).

Shape example

Now the hardest part—attaching this shape to a widget. I tried to pass the main bar object down to all the menu widgets, but since the bar is defined at the very top of the tree, passing it felt too inefficient. To resolve this, I had to refactor all the widgets with menus to align them on the same base height. Right-hand widgets were no issue since they are all just text icons with a wrapper. But the center widget was an issue—it has a different layout structure, so its menu was always a few pixels off the base height. Eventually I came up with a hybrid approach:

Item {
    id: root

    // manual vertical offset
    property int offset: 0
    // the source of the menu
    required property var boxParent
    // the content of the dropdown
    default property alias content: contentArea.data

    Item {
        id: dropdown

        visible: false
        width: contentArea.implicitWidth
        height: contentArea.implicitHeight

        x: {
            const mapped = root.boxParent.mapToItem(root.boxParent, 0, 0);
            return mapped.x + (root.boxParent.width / 2) - (dropdown.width / 2);
        }

        y: {
            if (root.offset > 0) {
                return root.offset;
            }

            let topItem = root.parent;
            while (topItem && topItem.parent) {
                topItem = topItem.parent;
            }

            if (topItem) {
                const boxToTop = root.boxParent.mapToItem(topItem, 0, 0);
                const parentToTop = root.parent.mapToItem(topItem, 0, 0);
                const result = boxToTop.y - parentToTop.y + root.boxParent.height + (Config.bar.padding) - 1;
                return result;
            }

            const mapped = root.boxParent.mapToItem(root.parent, 0, 0);
            const result = mapped.y + root.boxParent.height + (Config.bar.padding) - 1;
            return result;
        }

        Item {
            id: contentArea
            z: 0

            implicitWidth: children.length > 0 ? children[0].implicitWidth : 0
            implicitHeight: children.length > 0 ? children[0].implicitHeight : 0
        }

        HoverHandler {
            id: dropdownHover

            onHoveredChanged: {
                if (hovered) {
                    hideTimer.stop();
                } else {
                    hideTimer.start();
                }
            }
        }
    }

    Timer {
        id: hideTimer
        interval: 120
        repeat: false

        onTriggered: {
            if (!dropdownHover.hovered) {
                root.show = false;
                States.dropdownRevealed = false;
            }

            // right now only one menu can be opened
            // since they are triggered by hovers only
            if (States.dashboardPresent)
                States.dashboardPresent = false;
        }
    }
}

Dashboard dropdown:

Dropdown {
    id: dashboard
    boxParent: root

    Rectangle {
        color: "transparent"
        radius: Config.general.cornerRadius
        implicitWidth: layout.implicitWidth + 651
        implicitHeight: layout.implicitHeight + (Config.general.borderWidth > 0 ? 424 : 420)

        ColumnLayout {
            id: layout

            Item {
                Layout.fillHeight: true
                Layout.fillWidth: true

                Dashboard {}
            }
        }
    }
}

MouseArea {
    id: mouseArea
    anchors.fill: parent
    hoverEnabled: true

    onEntered: {
        dashboard.show = true;
        States.dashboardPresent = true;
    }

    onExited: {
        // do not close it right away
        dashboard.timer.start();
    }
}

Here I use a boxParent property that holds the parent container of the dropdown menu (a text icon in most cases) to calculate the position of the menu container. Horizontal position is simple—we just calculate the middle of a parent container. But things get complicated with vertical positions due to different layouts used by widget containers. So we have to traverse up to get the higher container that would have a more general base height that is not influenced by the size of text characters.

In the case of a bespoke widget, we can use an offset property to manually align the menu at the specific height.

#IPC

Some of the features could be called via external commands. To get the full list of all available commands, use quickshell ipc call show:

target audio
  function volumeUp(): void
  function toggleMute(): void
  function volumeDown(): void
target topbar
  function reveal(): void
  function launcher(): void
  function logout(): void
  function hide(): void
  function lock(): void
  function config(): void

These could be bound to custom key combinations or media keys in the compositor or called from the command line.

#Modules

Dashboard menu

The bar alone can handle most of the tasks. It can control workspaces, media players (via dashboard), volume levels and audio devices, restart the network stack, and cycle power modes.

#Launcher

Laucher

The launcher is a simple panel with a search bar and a list of applications matching the search request.

Loader {
    id: launcher
    active: States.launcherPresent && Config.desktop.launcher
    visible: launcher.active
    sourceComponent: Launcher {}
}

#Session panel

Session panel

The session panel consists of six buttons that execute commands. Each button has a customizable command and a key bind. Pretty standard stuff:

Session {
    SessionButton {
        command: Config.session.commands.lock
        keybind: Qt.Key_K
        text: "Lock"
        icon: "🔒"
    }

    SessionButton {
        command: Config.session.commands.logout
        keybind: Qt.Key_E
        text: "Logout"
        icon: "🚪"
    }

    SessionButton {
        command: Config.session.commands.suspend
        keybind: Qt.Key_S
        text: "Suspend"
        icon: "💤"
    }

    SessionButton {
        command: Config.session.commands.hibernate
        keybind: Qt.Key_H
        text: "Hibernate"
        icon: "⌚"
    }

    SessionButton {
        command: Config.session.commands.shutdown
        keybind: Qt.Key_P
        text: "Shutdown"
        icon: "⏻"
    }

    SessionButton {
        command: Config.session.commands.reboot
        keybind: Qt.Key_R
        text: "Reboot"
        icon: "🗘"
    }
}

#Lockscreen

Lock screen

The lock screen is triggered by an idle timer or manually via IPC command. It has a secure password box that uses a flashing border as feedback, a clock and battery widget, and a few session buttons.

Process {
    id: dpmsOff
    command: ["hyprctl", "dispatch", "dpms", "off"]
}

Process {
    id: dpmsOn
    command: ["hyprctl", "dispatch", "dpms", "on"]
}

Process {
    id: suspendProcess
    command: Config.session.commands.suspend
}

// screen lock
IdleMonitor {
    timeout: 600
    enabled: !States.keepAwake

    onIsIdleChanged: {
        if (isIdle) {
            lock.locked = true;
        }
    }
}

// disable the display
IdleMonitor {
    timeout: 690
    enabled: !States.keepAwake

    onIsIdleChanged: {
        if (isIdle) {
            dpmsOff.running = true;
        } else {
            dpmsOn.running = true;
        }
    }
}

// suspend the machine
IdleMonitor {
    timeout: 3600
    enabled: !States.keepAwake

    onIsIdleChanged: {
        if (isIdle) {
            suspendProcess.running = true;
        }
    }
}

LockContext {
    id: lockContext

    onUnlocked: {
        States.barEnabled = true;
        lock.locked = false;
    }
}

WlSessionLock {
    id: lock

    WlSessionLockSurface {
        color: "transparent"

        LockScreen {
            anchors.fill: parent
            context: lockContext
        }
    }
}

#Outro

This is the very beginning, but I am very happy with the results so far. QML is very easy to work with, and Quickshell already packs everything to deliver a full desktop environment.



Comment by replying to this post using a Mastodon or other ActivityPub/Fediverse account.