2026-08-18 12:36:52 +02:00
2026-08-18 12:36:52 +02:00
2026-07-26 21:16:55 +02:00
2026-07-26 21:16:55 +02:00
2026-08-18 12:36:52 +02:00
2026-08-18 12:36:52 +02:00
2026-08-18 12:36:52 +02:00

att_menu

A bottom desktop-environment panel for wlroots-based compositors, built on Quickshell. Three sections, nothing else:

Section Contents
Left Application menu built from .desktop entries, with search, and pinned quick-launch icons
Middle Open windows (task list), click to focus, middle-click to close
Right On-screen keyboard, volume and brightness sliders, wifi, bluetooth, and a session menu: log out, suspend, restart, shut down

No clock, no tray, no status readouts — it is designed to sit alongside a separate status bar.

Requirements

  • A wlroots-based compositor supporting wlr-layer-shell-unstable-v1 and wlr-foreign-toplevel-management-unstable-v1 (sway, Hyprland, river, niri, labwc, Wayfire, dwl, …). On river 0.4, additionally attwm — the window manager owns focus there, so the task list needs its socket (attwmctl focus-window); everything else works on plain river
  • Nix with flakes enabled
  • BlueZ (bluetoothd) for the bluetooth section — optional; without it, or on a machine with no adapter, the button is simply absent
  • NetworkManager for the wifi section, likewise optional. A NetworkManager configured with wifi.backend=iwd is fine — and is what this was built against — since the connection still goes through iwd underneath
  • squeekboard, as a systemd user unit, for the keyboard button — also optional; where the unit is not installed the button is absent
  • pipewire for the volume slider. Quickshell speaks to it directly, so there is no wpctl or pactl in the loop; without a default sink the slider is absent
  • brightnessctl for the brightness slider — bundled with the package, and optional in the sense that a machine with no controllable backlight (a desktop) simply does not get that row. It writes through logind where the session cannot write /sys/class/backlight itself, so no udev rule or setuid binary is needed

The window list uses the foreign-toplevel protocol rather than any single compositor's IPC, so it works the same everywhere — except under river 0.4, where it uses attwm's socket instead, because foreign-toplevel cannot focus a window there. See the note at the end.

Running

Try it without installing:

nix run .

Iterate on the config from the working tree, with hot reload on file save:

nix develop
qs --path .

Install into a profile:

nix profile install .
att_menu

Autostart

sway (~/.config/sway/config):

exec att_menu
bindsym $mod+d exec att_menu ipc call launcher toggle

Hyprland (~/.config/hypr/hyprland.conf):

exec-once = att_menu
bind = SUPER, D, exec, att_menu ipc call launcher toggle

river 0.4 with attwm (~/.config/river/init) — attwm owns key bindings, so the launcher binding goes in its config.zig as a spawn action:

att_menu &
exec attwm

Either order works: whichever starts first waits for the other.

The launcher, controls, wifi, bluetooth and power IPC targets each accept toggle, open and close. When opened by keybind the menu appears on the screen holding the focused window. Only one menu is open at a time, so close closes whichever it is.

The osk target takes the same three and shows or hides the keyboard instead, which is what to bind to a hardware key or a tablet-mode switch:

att_menu ipc call osk toggle

The volume and brightness targets move the level without putting anything on screen, which is what the media keys want — volume takes up, down and mute, brightness takes up and down, each one step of the size configured in volumeStep / brightnessStep:

att_menu ipc call volume up
att_menu ipc call volume mute
att_menu ipc call brightness down

Behaviour

Placement. The panel is a layer-shell surface anchored to the left, right and bottom edges, on the Top layer. Because it is anchored rather than sized, the compositor resizes it on any output change — resolution, scale, or rotation — and it stays on the bottom edge in every orientation. It reserves an exclusive zone so maximised windows do not sit underneath.

One panel is created per screen and they follow monitor hotplug.

Window list. Left click focuses a window, or minimises it if it is already focused. Middle click closes it. The focused window is underlined, and a window that is not on screen — minimised, or on an unviewed tag under attwm — is dimmed. By default each panel shows only the windows on its own screen (see windowsPerScreen).

Under attwm there is nothing to minimise into: a window is on a tag or it is not. Left click therefore always means focus, and clicking a window parked on another tag brings that tag into view.

Launcher. Type to search across name, generic name, keywords, comment and desktop-entry id, ranked by match quality. Category chips filter the list when the search box is empty. Up/Down move, Enter launches, Escape closes, and clicking away closes.

The launcher is a full-screen overlay layer surface rather than an xdg-popup, which is what makes click-away dismissal and keyboard focus work on any wlroots compositor without compositor-specific focus grabs.

Quick launch. The ids in quickLaunch are pinned as icons next to the app menu, in the order listed, and clicking one launches that entry exactly as the menu would — field codes stripped, Terminal=true honoured, and its own systemd scope when the panel is a unit. An id that is not installed is skipped rather than left as a dead button, so the same list can be carried between machines.

On-screen keyboard. One button shows squeekboard, another click hides it, and the button is accented while the keyboard is up. squeekboard runs as a systemd user unit (oskUnit, squeekboard.service by default): showing starts it if it is not already running, hiding leaves it running, since stopping it would only make the next keypress wait for a fresh start. Whatever manages the unit — a tablet-mode hook, say — stays in charge of when it exists at all.

The keyboard also raises and lowers itself when a text field takes or loses focus, so the button follows squeekboard's own Visible property rather than remembering what it last asked for, and stays right either way.

Volume and brightness. One button carries the speaker and the volume of the default sink; clicking it opens a small panel holding both sliders. Drag or press anywhere along a slider to set it, scroll it to nudge it, and scroll the panel button itself to change the volume without opening anything. Clicking the speaker — on the button with the middle mouse button, or in the panel — mutes. Up/Down pick a slider, Left/Right move it a step, Enter mutes, Escape closes.

Volume goes through pipewire directly and follows the default sink, so plugging in a headset moves the slider with the sound. Moving the slider unmutes: a slider that changes a number and produces no sound reads as broken.

Brightness goes through brightnessctl, which falls back to logind when the session cannot write /sys/class/backlight itself. It never goes fully dark (minBrightness), since on most laptops the only way back from a black screen is a hardware key. sysfs raises no change events, so the current value is re-read on a timer — but only while the panel is open, and the panel is what sees the hardware keys move it. Rapid changes coalesce onto the newest, so a drag never queues up a hundred processes.

The volume row is absent where pipewire has no default sink, and the brightness row where there is no controllable backlight; with neither, the whole button goes.

Wifi. The button shows signal strength in the arcs and the SSID in use next to them, struck through when the radio is off. The menu lists the networks in range — connected first, then saved, then by strength — with the switch in the header powering the radio. Clicking a network connects it, clicking the connected one disconnects, and joining a secured network that is not already saved opens a passphrase field at the bottom of the menu (Enter joins, Escape cancels). Right-click a saved network to forget it, which is also the way out if a wrong passphrase gets saved. Up/Down move, Enter activates.

Scanning only runs while the menu is open, so the panel is not keeping the radio busy in the background. Networks are ordered by signal in quarters rather than by the raw value, so rows do not reshuffle under the cursor as strength drifts.

Quickshell speaks to NetworkManager, which is the only backend it implements. On a system where NetworkManager is configured with wifi.backend=iwd, iwd is still what associates — this just avoids racing NetworkManager's own policy by driving iwd behind its back.

Bluetooth. The button shows adapter state at a glance — struck through when the radio is off, accented while something is connected — followed by the battery level of the first connected device that reports one. The menu lists the devices bluetoothd already knows about (paired or bonded), connected ones first; clicking a row connects it, clicking a connected row disconnects it, and the switch in the header powers the adapter. Up/Down move, Enter toggles the selected device, Escape closes.

It is deliberately not a pairing UI: discovery is never turned on, so nothing transient appears in the list. Pair new devices with bluetoothctl or a settings app once, and they show up here afterwards. Battery levels come from BlueZ, so they appear only for devices that report them (most headsets, some mice) and only while connected.

The whole section hides itself on machines with no bluetooth adapter, or when bluetoothd is not running.

Session actions. One button opens a menu holding log out, suspend, restart and shut down, in whatever order powerActions lists them. Actions marked dangerous (log out, restart, shut down) ask for confirmation first; suspend does not. Up/Down move, Enter runs, Escape closes — and the menu opens with the first non-destructive action selected, so Enter straight after opening can never shut the machine down.

Logging out is compositor-specific and is detected at runtime — Hyprland, sway, niri and river are recognised, with loginctl terminate-session as the fallback. On river it goes through attwmctl exit-session when attwm is answering, since river 0.4 has no riverctl.

Configuration

Everything tunable lives in two singletons; edit and restart (or just save, when running with qs --path .).

  • config/Theme.qml — colours, sizes, fonts, animation timings. barHeight is here.
  • config/Config.qml — behaviour:
    • layer, namespace, reserveSpace — layer-shell placement. The namespace (att_menu) is what compositor rules target, e.g. sway's layer_effects.
    • windowsPerScreen — set false to mirror every window onto every panel.
    • windowButtonMinWidth / windowButtonMaxWidth
    • launcherWidth / launcherHeight — clamped to the screen, so a rotated or small output still gets a usable menu.
    • terminal — argv prefix for entries with Terminal=true. Honours $TERMINAL; otherwise change the ["foot"] fallback to your terminal.
    • quickLaunch — desktop entry ids pinned next to the app menu, in order. Defaults to ["firefox", "org.kde.dolphin"]; set [] for none. The ids are desktop file names without the extension (ls /usr/share/applications).
    • showOsk — set false to drop the on-screen keyboard button entirely.
    • oskUnit — the systemd user unit squeekboard runs as.
    • showVolume / showBrightness — set false to drop that slider. With both off the panel button goes too.
    • volumeStep / brightnessStep — one wheel notch, one arrow key, one ipc call volume up. 0.05 is five percent.
    • minBrightness — floor on the backlight, as a fraction of its maximum.
    • controlsMenuWidth — width of the slider panel, clamped to the screen.
    • showWifi — set false to drop the wifi button entirely.
    • wifiLabelMaxWidth — cap on the SSID shown on the panel.
    • wifiMenuWidth / wifiMenuHeight — also clamped to the screen.
    • showBluetooth — set false to drop the bluetooth button entirely.
    • bluetoothMenuWidth / bluetoothMenuHeight — also clamped to the screen.
    • confirmDangerous — set false to skip the confirmation dialog.
    • powerMenuWidth — width of the session menu.
    • powerActions — the rows in the session menu. Reorder, remove, or add entries; each has a label, an icon name, a fallback glyph, an argv command, and a dangerous flag.

Layout

shell.qml              Root: one Bar per screen, plus the menu IPC handlers
config/Theme.qml       Colours, sizing, fonts
config/Config.qml      Behaviour, power actions, compositor detection
services/Session.qml   Which menu is open, and on which screen
services/AppLauncher.qml  Desktop-entry launching
services/Attwm.qml     attwm's IPC socket, when there is one
services/Osk.qml       squeekboard: its unit, and its DBus state
services/Audio.qml     The default pipewire sink: volume and mute
services/Brightness.qml The backlight: sysfs to read, brightnessctl to write
modules/Bar.qml        The panel window and its three sections
modules/AppMenu.qml    Left: launcher button
modules/Launcher.qml   The application menu overlay
modules/AppEntry.qml   One row in the menu
modules/QuickLaunch.qml Left: pinned app icons
modules/WindowList.qml Middle: task list
modules/WindowButton.qml  One task button
modules/OskMenu.qml    Right: on-screen keyboard button
modules/ControlsMenu.qml Right: volume button
modules/ControlsPanel.qml The volume and brightness sliders overlay
modules/WifiMenu.qml   Right: wifi button
modules/WifiPanel.qml  The network list overlay
modules/WifiNetworkEntry.qml One network row
modules/BluetoothMenu.qml Right: bluetooth button
modules/BluetoothPanel.qml The device list overlay
modules/BluetoothDeviceEntry.qml One device row
modules/PowerMenu.qml  Right: session button, and running the actions
modules/PowerPanel.qml The session menu overlay
modules/ConfirmDialog.qml Confirmation for destructive actions
components/AppIcon.qml    Icon resolution with fallbacks
components/PanelButton.qml Shared hover/press surface
components/BluetoothGlyph.qml The bluetooth rune, drawn
components/WifiGlyph.qml      The wifi fan, drawn
components/KeyboardGlyph.qml  The keyboard, drawn
components/VolumeGlyph.qml    The speaker, drawn
components/BrightnessGlyph.qml The sun, drawn
components/ToggleSwitch.qml   On/off switch
components/Slider.qml         0-1 slider, touch sized
components/SliderRow.qml      A slider with a glyph, label and reading

Notes

  • Why the window list has two sources. river 0.4 is non-monolithic: it ships no window management policy and hands the job to an external client speaking river-window-management-v1, which for this setup is attwm. river still advertises wlr-foreign-toplevel-management, so a task list built on it shows the right titles and tracks focus — but that protocol's activate request has nowhere to go. Focus is set solely by the window manager through river_seat_v1.focus_window, and the window management protocol has no activate_requested event to forward the request through, so river drops it and a click does nothing at all. services/Attwm.qml therefore takes over under river, sourcing the list from attwm's socket and focusing with focus-window <id>. Matching the two protocols up is not needed: river's window identifier is shared between them — which is just as well, since Quickshell's Toplevel does not expose it, and app id plus title is not unique (two terminals in the same directory).
  • Quickshell's Socket cannot be reconnected in place. A connect attempt against a socket that does not exist yet fails silently — no state change is reported — and re-asserting connected afterwards does nothing. So the socket lives in a Loader that gets thrown away and rebuilt on a timer, which is what lets the panel and attwm be started in either order.
  • Quickshell's DesktopEntry.execute() intentionally ignores Terminal=true and does not strip Exec field codes, so entries would be launched with a literal %U argument. services/AppLauncher.qml handles both instead.
  • Quickshell.execDetached detaches a launched app from the panel process, but not from its control group. Run as a systemd user unit — which is how tablet mode starts the panel — stopping the unit signals every process in the group, so the panel would take down every app it had ever launched. AppLauncher puts each app in a transient scope of its own with systemd-run --user --scope, and only when INVOCATION_ID says the panel is a unit: started from a shell or by the compositor it shares the session's group, where wrapping buys nothing. A scope rather than a service, so the app inherits the environment it needs — WAYLAND_DISPLAY above all.
  • Why the keyboard button shells out. Quickshell has no generic DBus binding, and squeekboard is driven entirely over DBus, so services/Osk.qml uses busctl: a call per request, and one long-lived busctl --json=short monitor as the state feed, parsed a line at a time. A monitor connection sees signals it is not the destination of, which is what lets the button follow a keyboard raised by something else. The two match rules deliberately do not name a sender: dbus-broker does not deliver the bus driver's own signals to a monitor that asks for them that way, and NameOwnerChanged — how the panel notices squeekboard going away, since it does not announce that itself — would never arrive. Showing retries the call for a few seconds because the unit goes active a moment before squeekboard claims its name.
  • The quick-launch icons resolve their ids by scanning DesktopEntries rather than calling byId. A function call in a binding creates no dependency on the model, so it would be evaluated once against a still-empty entry list and stay empty; scanning applications.values makes the binding re-run when the scan finishes, and when an app is installed or removed later.
  • qs ipc call cannot reach a handler function named show: the name collides with the ipc show subcommand and the call silently turns into a listing. The osk target uses open and close instead, which matches the menu targets anyway.
  • Icons are resolved through the Qt icon theme, falling back to the last segment of a reverse-DNS app id (org.qutebrowser.qutebrowserqutebrowser), then to a lettered tile. The packaged build adds Adwaita and hicolor to XDG_DATA_DIRS so the session-action icons resolve on minimal systems.
  • The bluetooth rune and the wifi fan are drawn with QtQuick.Shapes instead of pulled from the icon theme. Themes ship them only as symbolic (black-on-transparent) SVGs, which Qt renders as-is — they would be invisible on the dark panel. Drawing them also makes them recolourable, which is how adapter state is shown, and lets the wifi arcs light up one at a time.
  • The flake pins nixpkgs to the current NixOS stable release, nixos-26.05.
S
Description
No description provided
Readme 177 KiB
Languages
QML 98%
Nix 2%