From 202879eb0ecde9fe04c12fac6320ceca9f3273bb Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 26 Jul 2026 13:48:27 +0200 Subject: [PATCH] add attwm support --- CLAUDE.md | 24 ++++++++ README.md | 36 +++++++++-- config/Compositor.qml | 35 +++++++++++ config/qmldir | 1 + services/Attwm.qml | 126 ++++++++++++++++++++++++++++++++++++++ services/qmldir | 1 + widgets/Bar.qml | 20 ++++-- widgets/LayoutSymbol.qml | 42 +++++++++++++ widgets/WorkspaceChip.qml | 66 ++++++++++++++++++++ widgets/Workspaces.qml | 97 ++++++++++++++++++----------- 10 files changed, 404 insertions(+), 44 deletions(-) create mode 100644 config/Compositor.qml create mode 100644 services/Attwm.qml create mode 100644 widgets/LayoutSymbol.qml create mode 100644 widgets/WorkspaceChip.qml diff --git a/CLAUDE.md b/CLAUDE.md index fee70f9..f594467 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,30 @@ the QML side parses those lines into `tempPath`/`batPath`, which then feed to pick up layer-surface fixes ahead of release. - Native Quickshell services back some modules: `Quickshell.I3` (workspaces), `Quickshell.Services.SystemTray` (tray), PipeWire (volume). +- **Window manager backends are picked by `config/Compositor.qml`**, a singleton + that reads *only* environment variables — Hyprland's instance signature, + `$SWAYSOCK`/`$I3SOCK`, else attwm as the fallback (it announces nothing of its + own, being a river client rather than a compositor). **Never let the detector + do IPC**, and **never read a backend singleton outside its guard**: touching + `Attwm.x` / `I3.x` / `Hyprland.x` instantiates it, and an idle backend sits + there retrying a socket that will never answer. Every use is behind + `Compositor.isAttwm && …` or a `?:` whose untaken branch is never evaluated — + that short-circuit *is* the mechanism. It's verifiable: under attwm the log + has no `$I3SOCK is unset` line, under Sway it does. +- **attwm** (`services/Attwm.qml`, adapted from the reference client in that + repo — keep the two in step) is one `Socket` doing both directions: it + `subscribe`s for a JSON state object per change and writes `attwmctl`-grammar + commands back down the same connection, so a click costs no process spawn. + Its model is dwm's, not i3's — nine fixed tags addressed by *bitmask* + (`tags`/`occupied`), several viewable at once, per output, and no urgency + (the protocol has no attention-request event). `tagCount` deliberately + defaults to 0, not 9, so a wrong detection guess renders nothing instead of a + row of dead tags. `state` is left stale on disconnect so an attwm restart + freezes the bar rather than blanking it. +- Every backend renders through **`widgets/WorkspaceChip.qml`**, whose `dimmed` + and `dot` are presentational rather than semantic — "empty" means different + things per backend (i3 only lists workspaces that exist; attwm always + publishes all nine tags), so the call site decides. - **Making a module interactive (popups/drawers):** wrap the module's `Pill` in an `Item` (forward `implicitWidth`/`implicitHeight` from the pill so the bar's RowLayout still sizes it), add a `MouseArea`, and open a `Quickshell.PopupWindow`; diff --git a/README.md b/README.md index d0fbacb..b89daa0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # quickshell-bar -A Wayland status bar for [Sway](https://swaywm.org/), built with +A Wayland status bar for [Sway](https://swaywm.org/), i3, +[Hyprland](https://hypr.land/) and attwm (a dwm-like window manager for +[river](https://codeberg.org/river/river)), built with [Quickshell](https://quickshell.outfoxxed.me/) and packaged with Nix flakes. ![bar](docs/bar.png) @@ -11,7 +13,8 @@ Left → right: | Module | Source | |-------------|------------------------------------------| -| Workspaces | Sway/i3 IPC (`Quickshell.I3`) | +| Workspaces | Sway/i3 IPC (`Quickshell.I3`), Hyprland IPC, or attwm tags | +| Layout | attwm only — layout symbol, click to cycle | | Clock | local time (center) | | CPU | `/proc/stat` (per-core load bars + overall %) | | CPU temp | `coretemp`/`k10temp` hwmon, else thermal | @@ -33,7 +36,12 @@ use native Quickshell services. ## Requirements -- Sway (or i3) — the workspace module talks to `$SWAYSOCK`/`$I3SOCK`. +- A supported window manager, detected from the environment at startup: + Sway/i3 (`$SWAYSOCK`/`$I3SOCK`), Hyprland + (`$HYPRLAND_INSTANCE_SIGNATURE`), or attwm — assumed when neither of the + other two announces itself, and talking to + `$XDG_RUNTIME_DIR/attwm-$WAYLAND_DISPLAY.sock` (override with + `$ATTWM_SOCKET`). - A running PipeWire session for the volume module. - Nix with flakes enabled (`experimental-features = nix-command flakes`). @@ -74,6 +82,23 @@ Or, if you install the package (e.g. into your system/Home-Manager profile), just `exec quickshell-bar`. The bar anchors to the top edge and reserves an exclusive zone, so windows tile beneath it automatically. +## Use it from attwm + +attwm is started by river, so launch the bar from river's init alongside it: + +```sh +#!/bin/sh +# ~/.config/river/init +quickshell-bar & +exec attwm +``` + +Tags replace workspaces: all nine are always shown, dim when empty, underlined +when they hold windows you aren't looking at. Left click views a tag, right +click toggles it into the view, middle click sends the focused window there. +The layout symbol sits to their right — click to cycle forward, right click +back. A bar can only map at all because attwm binds `river_layer_shell_v1`. + ## Configuration Everything is plain QML — edit and the bar hot-reloads. @@ -91,13 +116,16 @@ shell.qml entry point — one Bar per monitor config/ Theme.qml colours, sizes, fonts (singleton) Icons.qml Nerd Font glyphs (singleton) + Compositor.qml which window manager are we under? env only (singleton) services/ SysStats.qml reads /proc & /sys via FileView into reactive props (singleton) + Attwm.qml attwm IPC socket: state in, commands out (singleton) discover.sh one-shot hwmon/battery path discovery at startup widgets/ Bar.qml the PanelWindow + layout Pill.qml rounded container used by every module MetricPill.qml icon + value helper - Workspaces.qml Clock.qml CpuGraph.qml CpuTemp.qml + WorkspaceChip.qml one workspace/tag chip, shared by every backend + Workspaces.qml LayoutSymbol.qml Clock.qml CpuGraph.qml CpuTemp.qml Ram.qml Disk.qml Network.qml Volume.qml Tray.qml Battery.qml ``` diff --git a/config/Compositor.qml b/config/Compositor.qml new file mode 100644 index 0000000..b63bf0b --- /dev/null +++ b/config/Compositor.qml @@ -0,0 +1,35 @@ +pragma Singleton + +import Quickshell +import QtQuick + +// Which window manager is driving this session? Detected once, from the +// environment only. +// +// This deliberately does no IPC of its own. Widgets branch on these flags to +// pick a backend, and reading a flag must never be able to instantiate the +// service singleton for a window manager that isn't running -- an idle backend +// would sit there retrying a socket that will never answer. Anything that +// wants a backend gates on the matching flag first (`Compositor.isAttwm && +// Attwm.x`), relying on `&&` / `?:` to leave the other branch untouched. +Singleton { + // Hyprland always exports this signature. + readonly property bool _hyprSig: !!Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") + + // Sway and i3 both point clients at their IPC socket. + readonly property bool _i3Sock: !!Quickshell.env("SWAYSOCK") + || !!Quickshell.env("I3SOCK") + + // attwm exports no signature of its own: it is a window management client + // of river rather than a compositor, so there is nothing session-wide to + // look for. Treat it as the fallback -- an explicit socket override wins + // outright, otherwise assume attwm once the two that do announce + // themselves are ruled out. Guessing wrong is cheap: everything attwm + // renders also gates on `Attwm.connected`, so a session with no window + // manager we recognise shows nothing rather than a row of dead tags. + readonly property bool isAttwm: !!Quickshell.env("ATTWM_SOCKET") + || (!_hyprSig && !_i3Sock) + + readonly property bool isHyprland: !isAttwm && _hyprSig + readonly property bool isI3: !isAttwm && !isHyprland && _i3Sock +} diff --git a/config/qmldir b/config/qmldir index b255122..332922d 100644 --- a/config/qmldir +++ b/config/qmldir @@ -1,2 +1,3 @@ singleton Theme 1.0 Theme.qml singleton Icons 1.0 Icons.qml +singleton Compositor 1.0 Compositor.qml diff --git a/services/Attwm.qml b/services/Attwm.qml new file mode 100644 index 0000000..2ee1008 --- /dev/null +++ b/services/Attwm.qml @@ -0,0 +1,126 @@ +// Singleton service wrapping attwm's IPC socket. +// +// Adapted from the reference client that ships with attwm (quickshell/Attwm.qml +// in that tree); keep the two in step when the state schema changes. +// +// One connection does both jobs: it subscribes to state, and commands are +// written back down the same socket. attwm keeps subscribers connected after a +// command precisely so a bar does not need a second connection or a process +// spawn per click. +// +// Only instantiate this under attwm -- see Compositor.isAttwm. Merely reading a +// property here opens the socket and starts the reconnect timer. + +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + // Latest state from attwm, parsed. Left untouched when the connection + // drops, so a restart of attwm freezes the bar on the last known state + // rather than blanking it for the second it takes to reconnect. + property var state: ({}) + + readonly property var outputs: state.outputs ?? [] + readonly property var tagNames: state.tag_names ?? [] + readonly property bool locked: state.locked ?? false + readonly property bool connected: socket.connected + + // 0 until the first state object lands: the tag row is driven by this, and + // a default of 9 would paint a row of dead tags before (or without) attwm. + readonly property int tagCount: state.tag_count ?? 0 + + // The output the user is currently working on. + readonly property var focusedOutput: { + for (const o of outputs) + if (o.focused) return o; + return outputs.length > 0 ? outputs[0] : null; + } + + readonly property string focusedTitle: { + if (!focusedOutput) return ""; + for (const w of focusedOutput.windows) + if (w.focused) return w.title; + return ""; + } + + /// Look up an output by name, so a multi-monitor bar can show per-screen + /// state rather than the focused output's. + function outputFor(name) { + for (const o of outputs) + if (o.name === name) return o; + return null; + } + + /// Send a command. Same grammar as attwmctl, e.g. send("view 3"). + function send(command) { + if (socket.connected) socket.write(command + "\n"); + } + + // A tag is "occupied" if some window carries it, mirroring dwm's bar. + function tagOccupied(output, index) { + return output ? (output.occupied & (1 << index)) !== 0 : false; + } + + function tagActive(output, index) { + return output ? (output.tags & (1 << index)) !== 0 : false; + } + + Socket { + id: socket + + // attwm names its socket after the Wayland display, so several river + // sessions can run side by side without colliding. + path: { + const explicit = Quickshell.env("ATTWM_SOCKET"); + if (explicit) return explicit; + const dir = Quickshell.env("XDG_RUNTIME_DIR"); + const display = Quickshell.env("WAYLAND_DISPLAY") || "wayland-0"; + return `${dir}/attwm-${display}.sock`; + } + + connected: true + + onConnectionStateChanged: { + if (connected) { + // attwm replies with a full state object immediately, so there + // is no empty-bar flash on connect. + socket.write("subscribe\n"); + } else { + reconnect.running = true; + } + } + + parser: SplitParser { + onRead: line => { + // Command acknowledgements ("ok" / "err ...") share the stream + // with state objects; only the latter are JSON. + if (!line.startsWith("{")) return; + try { + root.state = JSON.parse(line); + } catch (e) { + console.warn("attwm: bad state line:", e); + } + } + } + } + + // attwm may be restarted independently of the bar; keep trying. + Timer { + id: reconnect + interval: 1000 + repeat: true + running: false + onTriggered: { + if (socket.connected) { + running = false; + } else { + socket.connected = true; + } + } + } +} diff --git a/services/qmldir b/services/qmldir index d2dbca6..d43cfa9 100644 --- a/services/qmldir +++ b/services/qmldir @@ -1 +1,2 @@ singleton SysStats 1.0 SysStats.qml +singleton Attwm 1.0 Attwm.qml diff --git a/widgets/Bar.qml b/widgets/Bar.qml index 1a0f653..4829f05 100644 --- a/widgets/Bar.qml +++ b/widgets/Bar.qml @@ -35,7 +35,7 @@ PanelWindow { // depend on which row anything sits in, so this can't bind-loop. readonly property real neededWidth: (Theme.gap + 2) * 2 // outer margins - + workspaces.implicitWidth + + leftRow.implicitWidth + Theme.gap * 4 // breathing room between clusters + metricsRow.implicitWidth + Theme.gap @@ -50,13 +50,23 @@ PanelWindow { color: Theme.barColor } - // row 1 left: workspaces - Workspaces { - id: workspaces + // row 1 left: workspaces, plus the layout symbol under attwm (hidden + // otherwise, and a hidden item takes no space in a RowLayout) + RowLayout { + id: leftRow anchors.left: parent.left anchors.leftMargin: Theme.gap + 2 y: (Theme.barHeight - height) / 2 - screen: panel.modelData + spacing: Theme.gap + + Workspaces { + screen: panel.modelData + Layout.alignment: Qt.AlignVCenter + } + LayoutSymbol { + screen: panel.modelData + Layout.alignment: Qt.AlignVCenter + } } // row 1 right: clock, tray diff --git a/widgets/LayoutSymbol.qml b/widgets/LayoutSymbol.qml new file mode 100644 index 0000000..b91d3cf --- /dev/null +++ b/widgets/LayoutSymbol.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Layouts +import "../config" +import "../services" + +// attwm's layout indicator: "[]=" master, "[M]" monocle, "|||" tabbed. Click +// cycles forward, right-click back -- dwm's bar binding. +// +// Layout is per output, so this shows the monitor the bar is on, not the +// focused one. Hidden entirely under any other window manager; the `&&` guards +// short-circuit before Attwm is touched, so the socket is never opened there. +MouseArea { + id: root + required property var screen + + readonly property var output: Compositor.isAttwm + ? Attwm.outputFor(screen ? screen.name : "") + : null + + visible: Compositor.isAttwm && Attwm.connected && !!output + implicitWidth: pill.implicitWidth + implicitHeight: pill.implicitHeight + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: event => Attwm.send(event.button === Qt.RightButton + ? "cycle-layout prev" + : "cycle-layout next") + + Pill { + id: pill + anchors.fill: parent + + Text { + text: root.output ? root.output.layout_symbol : "" + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + color: Theme.subtext + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/widgets/WorkspaceChip.qml b/widgets/WorkspaceChip.qml new file mode 100644 index 0000000..8f00ef6 --- /dev/null +++ b/widgets/WorkspaceChip.qml @@ -0,0 +1,66 @@ +import QtQuick +import "../config" + +// One workspace/tag chip. Every backend renders through this, so the left end +// of the bar looks the same whichever window manager is driving it. +// +// `dimmed` and `dot` are presentational rather than semantic on purpose: what +// counts as "empty" differs per backend (i3 only lists workspaces that exist, +// attwm always publishes all nine tags), so each call site decides and this +// stays a plain renderer. +Rectangle { + id: chip + + property string label + property bool active: false // shown on this monitor + property bool focused: false // shown *and* holding input focus + property bool urgent: false + property bool dimmed: false // exists, but nothing on it + property bool dot: false // has windows but isn't shown (dwm's corner square) + + signal clicked(int button) + + implicitWidth: Math.max(implicitHeight, text.implicitWidth + 16) + implicitHeight: Theme.barHeight - Theme.gap * 2 + width: implicitWidth + height: implicitHeight + radius: Theme.radius + + color: urgent ? Theme.red + : focused ? Theme.blue + : active ? Theme.surface1 + : Theme.surface0 + + Text { + id: text + anchors.centerIn: parent + text: chip.label + font.family: Theme.font + font.pixelSize: Theme.fontSize + font.bold: chip.focused || chip.urgent + color: (chip.focused || chip.urgent) ? Theme.mantle + : chip.dimmed ? Theme.overlay + : Theme.text + } + + // dwm puts this in the tag's top-left corner, but the chip is only as wide + // as one digit -- a corner mark lands on the glyph. Underlining it says the + // same thing and suits the rounded chips. + Rectangle { + visible: chip.dot + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + width: 6 + height: 2 + radius: 1 + color: Theme.blue + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: event => chip.clicked(event.button) + } +} diff --git a/widgets/Workspaces.qml b/widgets/Workspaces.qml index ba2ee53..f52eb2c 100644 --- a/widgets/Workspaces.qml +++ b/widgets/Workspaces.qml @@ -4,59 +4,86 @@ import Quickshell import Quickshell.I3 import Quickshell.Hyprland import "../config" +import "../services" -// Workspace switcher for a single monitor. Works on both Sway/i3 and Hyprland: -// the compositor is detected once from its instance env var, and the matching -// Quickshell backend model is used. The two backends expose the same item shape -// (name / active / focused / urgent / monitor.name / activate()), so a single -// chip renderer covers both. The `?:` on `model` only evaluates the taken -// branch, so the unused backend singleton is never instantiated (no stray IPC). +// Workspace switcher for a single monitor. Three backends, one chip renderer +// (WorkspaceChip): +// +// * Sway/i3 and Hyprland expose the same item shape (name / active / focused +// / urgent / monitor.name / activate()), so both feed the same Repeater. +// * attwm is a different model entirely -- nine fixed tags addressed by +// bitmask, several of which can be viewed at once -- so it gets its own +// Repeater over an index range. +// +// Exactly one of the two Repeaters is ever non-empty. The `?:` and `&&` guards +// only evaluate the taken branch, which is what keeps the unused backend +// singletons from being instantiated (no stray IPC) -- see Compositor. Row { id: root required property var screen // ShellScreen this bar lives on spacing: Theme.gap - // Which compositor are we under? Hyprland always exports this signature. - readonly property bool isHyprland: !!Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") - // Name of the output this bar is on, used to filter workspaces. readonly property string monitorName: screen ? screen.name : "" - Repeater { - model: root.isHyprland ? Hyprland.workspaces : I3.workspaces + // attwm keeps tags, layout and geometry per output, so the tag row on this + // bar reflects this monitor rather than whichever one has focus. + readonly property var attwmOutput: Compositor.isAttwm + ? Attwm.outputFor(monitorName) + : null - Rectangle { - id: chip + // ── Sway/i3 and Hyprland ──────────────────────────────────────────────── + Repeater { + model: Compositor.isAttwm ? 0 + : Compositor.isHyprland ? Hyprland.workspaces + : I3.workspaces + + WorkspaceChip { required property var modelData + // only show workspaces belonging to this monitor. Both I3Workspace // and HyprlandWorkspace expose .monitor as an object with a .name. visible: modelData.monitor && modelData.monitor.name === root.monitorName - width: visible ? Math.max(height, label.implicitWidth + 16) : 0 - height: Theme.barHeight - Theme.gap * 2 - radius: Theme.radius - readonly property bool isFocused: modelData.focused - readonly property bool isUrgent: modelData.urgent + label: modelData.name + active: modelData.active + focused: modelData.focused + urgent: modelData.urgent - color: isUrgent ? Theme.red - : isFocused ? Theme.blue - : modelData.active ? Theme.surface1 - : Theme.surface0 + onClicked: modelData.activate() + } + } - Text { - id: label - anchors.centerIn: parent - text: chip.modelData.name - font.family: Theme.font - font.pixelSize: Theme.fontSize - font.bold: chip.isFocused || chip.isUrgent - color: (chip.isFocused || chip.isUrgent) ? Theme.mantle : Theme.text - } + // ── attwm ─────────────────────────────────────────────────────────────── + Repeater { + // 0 until the socket has produced a state object, so nothing is drawn + // if we guessed the window manager wrong. + model: Compositor.isAttwm ? Attwm.tagCount : 0 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: chip.modelData.activate() + WorkspaceChip { + id: tag + required property int index + + readonly property bool viewing: Attwm.tagActive(root.attwmOutput, index) + readonly property bool occupied: Attwm.tagOccupied(root.attwmOutput, index) + + label: Attwm.tagNames[index] ?? (index + 1) + active: viewing + // Tags are per output and several outputs can view a tag at once; + // only the focused output's get the focused colour. + focused: viewing && !!root.attwmOutput && root.attwmOutput.focused + dimmed: !viewing && !occupied + dot: occupied && !viewing + + // dwm's mouse bindings: view / toggle into view / send window there. + onClicked: button => { + const n = index + 1; + if (button === Qt.RightButton) + Attwm.send(`toggle-view ${n}`); + else if (button === Qt.MiddleButton) + Attwm.send(`tag ${n}`); + else + Attwm.send(`view ${n}`); } } }