add attwm support

This commit is contained in:
2026-07-26 13:48:27 +02:00
parent 9058dd7926
commit 202879eb0e
10 changed files with 404 additions and 44 deletions
+24
View File
@@ -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. to pick up layer-surface fixes ahead of release.
- Native Quickshell services back some modules: `Quickshell.I3` (workspaces), - Native Quickshell services back some modules: `Quickshell.I3` (workspaces),
`Quickshell.Services.SystemTray` (tray), PipeWire (volume). `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 - **Making a module interactive (popups/drawers):** wrap the module's `Pill` in
an `Item` (forward `implicitWidth`/`implicitHeight` from the pill so the bar's an `Item` (forward `implicitWidth`/`implicitHeight` from the pill so the bar's
RowLayout still sizes it), add a `MouseArea`, and open a `Quickshell.PopupWindow`; RowLayout still sizes it), add a `MouseArea`, and open a `Quickshell.PopupWindow`;
+32 -4
View File
@@ -1,6 +1,8 @@
# quickshell-bar # 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. [Quickshell](https://quickshell.outfoxxed.me/) and packaged with Nix flakes.
![bar](docs/bar.png) ![bar](docs/bar.png)
@@ -11,7 +13,8 @@ Left → right:
| Module | Source | | 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) | | Clock | local time (center) |
| CPU | `/proc/stat` (per-core load bars + overall %) | | CPU | `/proc/stat` (per-core load bars + overall %) |
| CPU temp | `coretemp`/`k10temp` hwmon, else thermal | | CPU temp | `coretemp`/`k10temp` hwmon, else thermal |
@@ -33,7 +36,12 @@ use native Quickshell services.
## Requirements ## 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. - A running PipeWire session for the volume module.
- Nix with flakes enabled (`experimental-features = nix-command flakes`). - 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 just `exec quickshell-bar`. The bar anchors to the top edge and reserves an
exclusive zone, so windows tile beneath it automatically. 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 ## Configuration
Everything is plain QML — edit and the bar hot-reloads. Everything is plain QML — edit and the bar hot-reloads.
@@ -91,13 +116,16 @@ shell.qml entry point — one Bar per monitor
config/ config/
Theme.qml colours, sizes, fonts (singleton) Theme.qml colours, sizes, fonts (singleton)
Icons.qml Nerd Font glyphs (singleton) Icons.qml Nerd Font glyphs (singleton)
Compositor.qml which window manager are we under? env only (singleton)
services/ services/
SysStats.qml reads /proc & /sys via FileView into reactive props (singleton) 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 discover.sh one-shot hwmon/battery path discovery at startup
widgets/ widgets/
Bar.qml the PanelWindow + layout Bar.qml the PanelWindow + layout
Pill.qml rounded container used by every module Pill.qml rounded container used by every module
MetricPill.qml icon + value helper 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 Ram.qml Disk.qml Network.qml Volume.qml Tray.qml Battery.qml
``` ```
+35
View File
@@ -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
}
+1
View File
@@ -1,2 +1,3 @@
singleton Theme 1.0 Theme.qml singleton Theme 1.0 Theme.qml
singleton Icons 1.0 Icons.qml singleton Icons 1.0 Icons.qml
singleton Compositor 1.0 Compositor.qml
+126
View File
@@ -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;
}
}
}
}
+1
View File
@@ -1 +1,2 @@
singleton SysStats 1.0 SysStats.qml singleton SysStats 1.0 SysStats.qml
singleton Attwm 1.0 Attwm.qml
+15 -5
View File
@@ -35,7 +35,7 @@ PanelWindow {
// depend on which row anything sits in, so this can't bind-loop. // depend on which row anything sits in, so this can't bind-loop.
readonly property real neededWidth: readonly property real neededWidth:
(Theme.gap + 2) * 2 // outer margins (Theme.gap + 2) * 2 // outer margins
+ workspaces.implicitWidth + leftRow.implicitWidth
+ Theme.gap * 4 // breathing room between clusters + Theme.gap * 4 // breathing room between clusters
+ metricsRow.implicitWidth + metricsRow.implicitWidth
+ Theme.gap + Theme.gap
@@ -50,13 +50,23 @@ PanelWindow {
color: Theme.barColor color: Theme.barColor
} }
// row 1 left: workspaces // row 1 left: workspaces, plus the layout symbol under attwm (hidden
Workspaces { // otherwise, and a hidden item takes no space in a RowLayout)
id: workspaces RowLayout {
id: leftRow
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: Theme.gap + 2 anchors.leftMargin: Theme.gap + 2
y: (Theme.barHeight - height) / 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 // row 1 right: clock, tray
+42
View File
@@ -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
}
}
}
+66
View File
@@ -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)
}
}
+62 -35
View File
@@ -4,59 +4,86 @@ import Quickshell
import Quickshell.I3 import Quickshell.I3
import Quickshell.Hyprland import Quickshell.Hyprland
import "../config" import "../config"
import "../services"
// Workspace switcher for a single monitor. Works on both Sway/i3 and Hyprland: // Workspace switcher for a single monitor. Three backends, one chip renderer
// the compositor is detected once from its instance env var, and the matching // (WorkspaceChip):
// Quickshell backend model is used. The two backends expose the same item shape //
// (name / active / focused / urgent / monitor.name / activate()), so a single // * Sway/i3 and Hyprland expose the same item shape (name / active / focused
// chip renderer covers both. The `?:` on `model` only evaluates the taken // / urgent / monitor.name / activate()), so both feed the same Repeater.
// branch, so the unused backend singleton is never instantiated (no stray IPC). // * 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 { Row {
id: root id: root
required property var screen // ShellScreen this bar lives on required property var screen // ShellScreen this bar lives on
spacing: Theme.gap 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. // Name of the output this bar is on, used to filter workspaces.
readonly property string monitorName: screen ? screen.name : "" readonly property string monitorName: screen ? screen.name : ""
Repeater { // attwm keeps tags, layout and geometry per output, so the tag row on this
model: root.isHyprland ? Hyprland.workspaces : I3.workspaces // bar reflects this monitor rather than whichever one has focus.
readonly property var attwmOutput: Compositor.isAttwm
? Attwm.outputFor(monitorName)
: null
Rectangle { // ── Sway/i3 and Hyprland ────────────────────────────────────────────────
id: chip Repeater {
model: Compositor.isAttwm ? 0
: Compositor.isHyprland ? Hyprland.workspaces
: I3.workspaces
WorkspaceChip {
required property var modelData required property var modelData
// only show workspaces belonging to this monitor. Both I3Workspace // only show workspaces belonging to this monitor. Both I3Workspace
// and HyprlandWorkspace expose .monitor as an object with a .name. // and HyprlandWorkspace expose .monitor as an object with a .name.
visible: modelData.monitor && modelData.monitor.name === root.monitorName 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 label: modelData.name
readonly property bool isUrgent: modelData.urgent active: modelData.active
focused: modelData.focused
urgent: modelData.urgent
color: isUrgent ? Theme.red onClicked: modelData.activate()
: isFocused ? Theme.blue }
: modelData.active ? Theme.surface1 }
: Theme.surface0
Text { // ── attwm ───────────────────────────────────────────────────────────────
id: label Repeater {
anchors.centerIn: parent // 0 until the socket has produced a state object, so nothing is drawn
text: chip.modelData.name // if we guessed the window manager wrong.
font.family: Theme.font model: Compositor.isAttwm ? Attwm.tagCount : 0
font.pixelSize: Theme.fontSize
font.bold: chip.isFocused || chip.isUrgent
color: (chip.isFocused || chip.isUrgent) ? Theme.mantle : Theme.text
}
MouseArea { WorkspaceChip {
anchors.fill: parent id: tag
cursorShape: Qt.PointingHandCursor required property int index
onClicked: chip.modelData.activate()
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}`);
} }
} }
} }