tablero 0.2.2

A fast, native Wayland status bar for Hyprland
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
# tablero

A fast, owned Hyprland status bar — a Waybar replacement built on
`wlr-layer-shell` with software (CPU) rendering through shared memory.

## Install

tablero requires a Wayland compositor with `wlr-layer-shell` support. It is
developed for Hyprland and builds against PipeWire, libudev, and xkbcommon.

On Debian or Ubuntu, install the native build dependencies first:

```sh
sudo apt-get install libpipewire-0.3-dev libudev-dev libxkbcommon-dev pkg-config
```

On Arch Linux:

```sh
sudo pacman -S --needed pipewire libxkbcommon pkgconf rust
```

Then install the latest release from crates.io with the current stable Rust
toolchain:

```sh
cargo install tablero --locked
```

Run `tablero` from inside a Hyprland session. Set `RUST_LOG=info` to see startup
and lifecycle logging.

## Project layout

tablero is one publishable package with a library and binary target. The library
keeps the implementation split into compositor-independent config, rendering,
and widget modules plus the Wayland surface and producer integrations. The
binary loads the user's config and starts the Wayland event loop.

## Build & run

```sh
cargo build --all-targets
cargo test --all          # config + render + widget unit/integration tests (no compositor needed)
cargo run -p tablero      # opens the bar; run from within a Hyprland session
```

Set `RUST_LOG=info` for startup and lifecycle logging:

```sh
RUST_LOG=info cargo run -p tablero
```

## What it does

- Opens a **top-anchored** layer-shell surface spanning the output width, with an
  **exclusive zone** equal to its height so tiled windows do not overlap it. The
  height (default 32px) is configurable.
- Shows **one bar per monitor**: it tracks Wayland output lifecycle and opens a
  surface on each output as it appears, tearing it down when the output is
  unplugged — hotplug never crashes the bar or leaks stale state. Each output's
  bar can be configured independently (widget set and visual overrides), and its
  **workspace widget is scoped to that monitor** — it shows only that monitor's
  workspaces and highlights *its* active one, matching Hyprland's per-monitor
  workspace model.
- Renders, left to right, a set of **widgets** driven by a typed message
  architecture, each repainting only when its visible state actually changes:
  - **Workspaces** — the Hyprland workspace set, active one bracketed and drawn
    in the accent color. **Click a workspace to switch to it.**
  - **Title** — the active window's title on the bound monitor (default in the
    center zone). Each bar shows its own monitor's focused window; focus changes
    on one output leave other bars unchanged. Long titles are truncated with
    `` at 100 characters by default; `icon = "none"` keeps the default
    glyphless look.
  - **Clock** — a live local clock (`HH:MM`).
  - **Battery** — percentage and charge state via UPower (blank when no battery
    is present).
  - **Backlight** — screen brightness read directly from Linux sysfs. **Opt-in**:
    add `"backlight"` to a zone. Scroll up/down over the widget to adjust the
    selected device through systemd-logind, without `brightnessctl` or another
    status bar. Device state follows native udev events with periodic recovery
    polling, and the widget reserves no slot when no screen backlight is present.
  - **System** — CPU and memory load sampled from procfs.
  - **Updates** — available Arch Linux package updates, checked immediately and
    every five minutes. **Opt-in** in the built-in config and enabled first in the
    ARC preset's right zone. Official repository updates come from the safe,
    isolated database used by `checkupdates`; when `paru` is installed, pending
    AUR updates from `paru -Qua` are added. The widget hides when the total is
    zero or the required official check is unavailable.
  - **Network** — connection state via NetworkManager (disconnected, wired,
    wireless, or unknown), with the Wi-Fi SSID shown when available; blank when
    NetworkManager is unavailable.
  - **Tray** — StatusNotifierItem (SNI) system-tray icons from background apps,
    over DBus. **Opt-in**: not in the default set; add `"tray"` to `widgets` to
    enable it. Left-click activates conventional items and opens menu-only
    AppIndicators; right-click opens the item's native DBusMenu, including nested,
    disabled, separator, checkbox, and radio entries. Items without an exported
    menu receive the SNI `ContextMenu` fallback. Under a compositor with no native
    `StatusNotifierWatcher` (e.g. Hyprland) the bar hosts one itself. Icons come
    from an embedded pixmap or a themed PNG; an item with neither falls back to
    its initial letter.
  - **Bluetooth** — the local adapter state (powered on/off) and connected-
    device count, read from BlueZ over DBus. Shows `on`, `off`, `N connected`,
    or `unavailable` when no adapter is present (desktops without Bluetooth
    hardware always reserve the slot). **Opt-in**: not in the default set;
    add `"bluetooth"` to a zone to enable it. Any widget can carry an
    `on-click` config field — when set, a left-click inside the widget spawns
    that executable directly (no shell), so a typical use is `on-click =
    "/path/to/blueman-manager"` to launch a Bluetooth manager on click.
  - **Volume** — the active output sink's level (`Vol N%`) and mute state
    (`Mute`), read over the native PipeWire wire protocol. The glyph swaps
    between headphones / speakers / monitor / phone / TV based on the
    active sink's device kind, so the user can tell at a glance which
    output the level they see is on. **Opt-in**: not in the default set;
    add `"volume"` to a zone to enable it. The widget reserves no slot
    when no PipeWire server is reachable (or no output sink exists),
    matching how the `network` and `system` widgets treat an absent
    source. The `on-click` field wires a click to a launcher (e.g.
    `on-click = "/usr/bin/pavucontrol"`).
  - **Notifications** — a bell indicator for swaync (SwayNotificationCenter),
    followed natively over DBus (no `swaync-client` subprocess). A small dot
    in the bell's corner while notifications are pending; slashed and dimmed
    under Do-Not-Disturb. **Left-click toggles the swaync panel, right-click
    toggles DND.** **Opt-in**: not in the default set; add `"notifications"`
    to a zone to enable it. Requires swaync running as the notification
    daemon; while it is not on the bus the widget reserves no slot, and it
    reappears automatically when swaync (re)starts.
  - **Power profiles** — the active power-profiles-daemon profile, followed
    natively over the system DBus. Enabled by default after the network module;
    it hides when the daemon is unavailable. Left-click rotates forward through
    the advertised profiles, right-click rotates backward, and hovering shows
    the configured profile and driver tooltip.
  - **Hypridle** — exact same-user process state read natively from procfs, with
    no polling script or `pgrep`/`killall` helper. **Opt-in**: add `"hypridle"`
    to a zone. The lock glyph uses the widget accent while Hypridle is active and
    its foreground while inactive; left-click starts or stops the daemon and the
    tooltip reports the current state.
  - **Power** — a compact power glyph with configurable direct-launch actions.
    **Opt-in**: add `"power"` to a zone. `on-click = "wlogout"` opens the logout
    menu and `on-click-right = "hyprlock"` preserves a secondary lock action.
- Draws through `cosmic-text` + `tiny-skia`, committed via a `wl_shm` ARGB8888
  buffer.
- Handles **HiDPI / output scaling**: surface geometry stays in logical pixels
  (so the bar keeps a consistent apparent size across displays), while the
  shared-memory buffer is allocated at the output's physical pixel density and
  `set_buffer_scale` maps it back. Text and layout are scaled exactly once — no
  double-scaling. Integer-scale outputs are pixel-crisp; fractional-scale
  Hyprland setups render at the next integer scale and the compositor downscales,
  which stays sharp in practice.
- Pulls live data from **async producers** (Hyprland IPC, UPower, procfs,
  NetworkManager over DBus) running on an off-thread Tokio runtime; they reach
  the synchronous render loop only by sending messages through a `calloop`
  channel. The PipeWire volume source is the one exception: it runs on a
  dedicated OS thread (PipeWire's `MainLoop` is synchronous and
  file-descriptor-driven, unlike zbus), and reaches the Tokio runtime the
  same way — by sending `Msg::Volume`s through the cross-thread `MsgSender`.
- Wakes **only** for clock ticks (a `calloop` timer aligned to the wall-clock
  second), producer messages, pointer input, compositor configure events, or
  shutdown — there is no busy redraw loop and no frame-callback feedback cycle.

## Configuration

tablero reads an optional TOML file from
`$XDG_CONFIG_HOME/tablero/config.toml` (falling back to
`$HOME/.config/tablero/config.toml`). **The file is optional**: when it is
absent the bar runs on the documented defaults below. The document may be
partial — any field you omit falls back to its default, so you only specify what
you want to change.

Invalid configuration is a **hard error**, never a silent fallback: an unknown
key, an unknown widget name, or a malformed color stops startup with a clear
message naming the file.

An opinionated, ready-to-copy ARC Raiders-inspired desktop preset lives at
[`config.example.toml`](https://github.com/piny4man/tablero/blob/main/crates/tablero/config.example.toml).
It demonstrates the full styling surface and is intentionally different from
the built-in defaults documented below:

```sh
mkdir -p ~/.config/tablero
curl --fail --location \
  https://raw.githubusercontent.com/piny4man/tablero/main/crates/tablero/config.example.toml \
  --output ~/.config/tablero/config.toml
```

### Reference

Every value below is the built-in default.

```toml
# Bar height in logical pixels (scaled to the output's pixel density on HiDPI
# displays). The width always spans the output.
height = 32

[bar]
# Logical-pixel inset around the full module row and gap between adjacent modules.
margin = 0
gap = 0

# Modules are placed independently in left, center, and right zones. Valid names:
# "workspaces", "title", "clock", "battery", "system", "network", "tray",
# "bluetooth", "volume", "backlight", "notifications", "updates", "hypridle",
# "power", and "power-profiles-daemon". Repeats are de-duplicated, keeping first
# position.
modules-left = ["workspaces"]
modules-center = ["title"]
modules-right = ["clock", "battery", "system", "network", "power-profiles-daemon"]

[theme]
# Colors are "#rrggbb" hex strings (the leading "#" is optional).
background = "#181818" # fill behind every widget
foreground = "#eaeaea" # default text color
accent     = "#eaeaea" # emphasis color (e.g. the active workspace)

[font]
# family is unset by default, which uses the system default font. Uncomment to
# pick a specific family:
# family = "JetBrains Mono"
size = 16.0
```

| Key            | Type            | Default                                       | Notes                                                            |
| -------------- | --------------- | --------------------------------------------- | ---------------------------------------------------------------- |
| `height`       | integer (px)    | `32`                                          | Bar height; also drives the exclusive zone.                      |
| `bar.background` | hex color (opt.) | unset                                       | Bar fill; inherits `theme.background` when unset.                |
| `bar.margin`   | integer (px)    | `0`                                           | Inset around the full module row.                                |
| `bar.gap`      | integer (px)    | `0`                                           | Gap between adjacent modules in a zone.                          |
| `bar.modules-left` | list of strings | `["workspaces"]`                          | Modules packed against the left edge.                            |
| `bar.modules-center` | list of strings | `["title"]`                             | Modules centered independently of the edge zones.                |
| `bar.modules-right` | list of strings | `["clock", "battery", "system", "network", "power-profiles-daemon"]` | Modules packed against the right edge. Also accepts the opt-in modules `"tray"`, `"bluetooth"`, `"volume"`, `"backlight"`, `"notifications"`, `"updates"`, `"hypridle"`, and `"power"`. |
| `theme.background` | hex color   | `"#181818"`                                   | Fill behind every widget.                                        |
| `theme.foreground` | hex color   | `"#eaeaea"`                                   | Default text color.                                              |
| `theme.accent`     | hex color   | `"#eaeaea"`                                   | Emphasis color (e.g. the active workspace).                      |
| `font.family`  | string (opt.)   | unset → system font                           | Font family name.                                                |
| `font.size`    | float (px)      | `16.0`                                         | Text size.                                                       |

Per-widget tables support equipment-like fills and outlines without changing
the global theme:

```toml
[widget.clock]
background = "#292b1d"
foreground = "#f0d77b"
border = "#766b36"
border-width = 1
radius = 2
padding = 10
```

`background`, `foreground`, `accent`, and `border` accept the same six- or
eight-digit hex colors as the theme. `border-width`, `radius`, and `padding` are
logical pixels and scale with the output. A border is optional; when `border` is
set and `border-width` is omitted, the width defaults to one logical pixel.

The battery also supports `format` placeholders: `{icon}`, `{percent}`, and
`{state}`. State-specific colors can override the fill, text, and border while
charging without changing the normal or low-battery appearance:

```toml
[widget.battery]
format = "{icon} {percent}%"

[widget.battery.charging]
background = "#2df18520"
foreground = "#5ff5a0"
border = "#2df185"
```

Per-widget click handlers live on every `[widget.<name>]` table as
`on-click = "/path/to/executable"`. When set, a left-click inside that widget
spawns the file directly (no shell) — typical use is
`[widget.bluetooth] on-click = "/path/to/blueman-manager"` or
`[widget.volume] on-click = "/usr/bin/pavucontrol"`. The path may use
a leading `~` for the user's home, expanded at click time. Bare executable names
are resolved through `PATH`; arguments and shell syntax are not supported. Today
the bluetooth, volume, updates, and power widgets honor `on-click`. The power
widget also accepts `on-click-right` with the same direct-spawn semantics:

```toml
[bar]
modules-right = ["hypridle", "power", "clock"]

[widget.hypridle]
foreground = "#ff4a4d" # inactive
accent = "#7decff"     # active

[widget.power]
on-click = "wlogout"
on-click-right = "hyprlock"
```

The Arch package-updates module requires `checkupdates` from the official
`pacman-contrib` package. `paru` is optional; when available, its AUR count is
added to the official repository count. No Waybar helper module is required:

```sh
sudo pacman -S --needed pacman-contrib
```

```toml
[bar]
modules-right = ["updates", "clock"]

[widget.updates]
format = "{icon} {count}"
# Optional direct executable or script; no shell or arguments are implied.
# on-click = "/path/to/update-system"
```

Only `{count}` and `{icon}` are accepted in `format`. Checks run immediately and
then every 300 seconds. A missing or failed `paru` command falls back to official
updates; a missing or failed `checkupdates` command hides the module rather than
presenting a misleading partial total. Hovering the module lists each package's
installed and available versions, grouped by official repositories and AUR.

The backlight module has four module-specific fields:

```toml
[bar]
modules-right = ["backlight", "battery", "network"]

[widget.backlight]
device = "intel_backlight" # optional; auto-selects the largest range when absent
format = "{icon}  {percent}%"
format-icons = ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
scroll-step = 1
```

Only `{icon}` and `{percent}` are accepted in `format`. Icons are selected from
low to high brightness. Scrolling requires an active systemd-logind session;
display remains available if logind cannot perform writes.

The power-profiles-daemon module is enabled by default and uses the daemon's
advertised profile order for click rotation:

```toml
[widget.power-profiles-daemon]
format = "{icon}"
tooltip-format = "Power profile: {profile}\nDriver: {driver}"
tooltip = true

[widget.power-profiles-daemon.format-icons]
default = ""
performance = ""
balanced = ""
power-saver = ""
```

Both `format` and `tooltip-format` accept `{icon}`, `{profile}`, `{driver}`,
`{cpu_driver}`, and `{platform_driver}`. The module talks directly to the
official system D-Bus API and falls back to its legacy compatible name; it never
spawns `powerprofilesctl`.

### Per-monitor overrides

tablero shows one bar per output. By default every output runs on the global
settings above; add a `[[monitor]]` block to override a specific output, matched
by its Hyprland connector name (`hyprctl monitors` lists them — `DP-1`,
`HDMI-A-1`, `eDP-1`, …):

```toml
[[monitor]]
name = "DP-1"          # required: the connector to match
height = 40            # override just this output's height

[monitor.bar]
modules-center = ["clock"] # replace just this output's center zone

[monitor.theme]
accent = "#88c0d0"     # only the accent changes; background/foreground inherit

[monitor.font]
size = 18.0            # larger text on this monitor only

[[monitor]]
name = "eDP-1"         # the laptop panel: shorter bar, everything else global
height = 28
[monitor.widget.title]
background = "#2e3440" # give the title a pill on this output only
```

Every field **except `name` is optional** and overrides are shallow per field: a
field you omit keeps the output's global value (and within `[monitor.theme]` /
`[monitor.font]`, an omitted channel inherits the global theme/font rather than
resetting to the built-in default). An output whose connector matches no
`[[monitor]]` block — or that advertises no name — runs on the global defaults
unchanged.

| Key                  | Type            | Notes                                                                 |
| -------------------- | --------------- | --------------------------------------------------------------------- |
| `monitor` (`[[monitor]]`) | array of tables | One block per output you want to customize; omit entirely for a uniform bar. |
| `monitor.name`       | string          | **Required.** Hyprland connector name to match (`hyprctl monitors`).  |
| `monitor.height`     | integer (px)    | Overrides `height` on this output.                                    |
| `monitor.bar.background` | hex color (opt.) | Overrides the bar fill on this output.                           |
| `monitor.bar.margin` | integer (px)    | Overrides the module-row inset on this output.                        |
| `monitor.bar.gap`    | integer (px)    | Overrides the inter-module gap on this output.                        |
| `monitor.bar.modules-*` | list of strings | Replaces the named module zone on this output.                     |
| `monitor.theme.*`    | hex color       | Per-channel theme override; omitted channels inherit the global theme. |
| `monitor.font.*`     | family / size   | Per-field font override; omitted fields inherit the global font.      |
| `monitor.widget.*`  | per-widget table | Per-widget style override (e.g. `[monitor.widget.title] background`); folded per-field onto the global `[widget.<name>]` table. |

## Manual verification under Hyprland

The render, blit, widget, and config paths are unit- and integration-tested, but
surface placement and input need a live compositor. To verify on Hyprland:

1. From inside a Hyprland session, run `RUST_LOG=info cargo run -p tablero`.
2. Confirm a bar appears **pinned to the top** of the screen, spanning its full
   width, showing a dark background with the workspaces, **title**, clock,
   battery, system, network, and power-profile widgets in their configured zones.
3. Confirm the clock **advances once per second** and that the text changes
   exactly on the second boundary (the timer is second-aligned).
4. Confirm the **workspace indicator tracks Hyprland** — switching workspaces by
   any means updates which id is bracketed/accented — and that **clicking a
   workspace in the bar switches to it**.
5. Confirm tiled windows are **pushed down by the bar's height** and never render
   under it — this proves the exclusive zone is honored.
6. Confirm the process is **idle between updates**: `top`/`htop` should show ~0%
   CPU for `tablero` when nothing is changing (no busy loop).
7. Optionally inspect the surface: `hyprctl layers` should list a `tablero`
   namespace in the `top` layer on the active output.
8. Drop a `~/.config/tablero/config.toml` (e.g. change `height` or the `widgets`
   order), restart, and confirm the change takes effect; introduce a typo and
   confirm the process **refuses to start with a clear error** rather than
   silently ignoring it.
9. Verify **HiDPI scaling** on a scaled output. Set a scale on the monitor (e.g.
   `hyprctl keyword monitor <name>,preferred,auto,2` for integer 2×, or `,1.5`
   for fractional), then run the bar on it:
   - The bar keeps the **same apparent height and text size** as on an unscaled
     output — geometry is logical, so it does not shrink or balloon.
   - Text and widget edges stay **crisp**, not blurry: the buffer is rendered at
     the output's physical resolution (`RUST_LOG=info` logs `output scale changed
     to Nx` when the compositor reports the scale).
   - **Clicking a workspace** still switches to it — pointer hit-testing tracks
     the scaled layout. On a fractional scale (e.g. 1.5×) the bar renders at 2×
     and the compositor downscales; confirm it still looks sharp and clicks land.
10. Verify **multi-monitor** placement with at least two outputs (`hyprctl
    monitors` lists them). With the bar running:
    - Confirm **each monitor has its own bar**, pinned to the top of *that*
      output and spanning *its* width — not one bar stretched across both, and no
      output left bare.
    - Confirm each bar's **workspace widget shows only its own monitor's
      workspaces** and brackets/accents the workspace active *on that monitor*      switching workspaces on one screen updates that screen's bar without
      disturbing the other's (this mirrors Hyprland's per-monitor workspaces).
    - Confirm **clicking a workspace** on a given monitor's bar switches that
      monitor — input is routed to the surface it lands on.
    - Add a `[[monitor]]` block for one connector (e.g. a different `height`,
      `widgets`, or `[monitor.theme] accent`), restart, and confirm **only that
      monitor's bar changes** while the other keeps the global look.
    - **Hotplug**: unplug a monitor (or `hyprctl keyword monitor <name>,disable`)
      and confirm its bar disappears while the remaining bar(s) keep running;
      re-enable it and confirm a fresh bar reappears. `RUST_LOG=info` logs
      `output … added` / `output … removed` and the app must not crash or leave a
      stale surface (`hyprctl layers` should list exactly one `tablero` namespace
      per live output).
11. Verify the **system tray** with at least two real tray-producing apps. Add
    `"tray"` to `widgets` (it is opt-in), restart, then launch two SNI clients —
    e.g. **Discord** or **Element** (Electron, ARGB pixmaps), **Telegram
    Desktop** (themed icon name), **nm-applet** or **blueman-applet** (classic
    Qt/GTK trays), or `KStatusNotifierItem` apps. Confirm:
    - Each app's icon **appears** in the bar shortly after launch and
      **disappears** when the app quits — the host re-enumerates on every
      watcher registration/unregistration.
    - Left-clicking a conventional item **activates** the app through DBus
      `Activate`; a menu-only AppIndicator opens its menu instead.
    - Right-click opens the exported DBusMenu. Confirm nested entries,
      separators, disabled actions, checkboxes, and radio items render correctly,
      and selecting an enabled leaf performs its action. An item with no exported
      menu should receive its own SNI `ContextMenu` call instead.
    - An app that ships only an `IconName` (no pixmap) still renders, resolved
      against the icon theme; an app with neither shows its **initial letter**
      rather than vanishing or crashing.
    - Quitting an app **mid-session** does not crash the bar or leave a stale
      icon.

    **Protocol quirks** worth knowing when reading the code or debugging:
    - **No watcher under bare compositors.** Hyprland (and similar) run no
      `org.kde.StatusNotifierWatcher`, so tablero serves one itself and only
      defers to an existing watcher when the well-known name is already owned.
    - **Registration address is ambiguous.** Apps register either a bare bus
      name (icon lives at the default `/StatusNotifierItem`), or a bus name
      immediately followed by an object path (Ayatana/`libdbusmenu` apps use a
      per-item path like `/org/ayatana/NotificationItem/foo`). The host splits
      at the first `/`; a bare path with no bus name is unaddressable and
      skipped.
    - **`ItemIsMenu` is often omitted.** When a valid `Menu` path exists and the
      property is absent, tablero treats the item as menu-only for AppIndicator
      compatibility. An explicit `false` preserves left-click activation.
    - **Pixmap bytes are ARGB32, network byte order**, *not* the RGBA the
      renderer wants — each pixel is reordered and alpha-premultiplied on
      decode. Items often ship several sizes; the largest by area is chosen.
    - **Icon-theme lookup is pragmatic, not full XDG.** `IconThemePath` (when
      set) leads, then common `hicolor` app/status sizes and `/usr/share/pixmaps`
      `index.theme` inheritance is not walked, which covers typical bar apps
      without the full resolver.
12. Press the compositor's close path for the layer (or terminate the session)
    and confirm the process exits cleanly: each surface is removed via the
    `closed` handler, and the process shuts down once the last bar is gone.
13. Verify the **title widget** (default in `modules-center`). With at least
    two outputs (`hyprctl monitors`), focus a window on output A and confirm
    that only output A's bar updates; output B's bar should keep its
    previous title. Switching focus between windows on output A should
    update only A's title within one frame. A long title (browser tab with
    a full URL, music player track) should cap at `100` characters with a
    trailing `` and never overflow the center zone. Closing the last
    focused window should leave the center blank.
14. Verify the **bluetooth widget** (opt-in: add `"bluetooth"` to a zone).
    With BlueZ running:
    - The widget appears with the bluetooth glyph and a label of `on`, `off`,
      `N connected`, or `unavailable`. On a desktop with no Bluetooth
      hardware it should always reserve a slot and read `unavailable`; on a
      laptop with the adapter powered off it reads `off`.
    - Toggle the adapter with `bluetoothctl power on` / `bluetoothctl power
      off` and confirm the label changes by the next poll (the producer
      polls BlueZ every two seconds, so allow up to a couple of seconds
      for the flip).
    - Pair and connect a device (e.g. `bluetoothctl connect <mac>`); confirm
      the label flips to `1 connected` (or higher) and back to `on` on
      disconnect.
    - Pair/unpair with the adapter powered off; the count stays at zero and
      the label stays `off`.
    - If a `[widget.bluetooth] on-click = "/path/to/executable"` is set,
      left-click the widget and confirm the configured process is spawned
      (no shell, direct exec). The script must be marked executable; a
      missing or non-executable path is logged as an error and does not
      crash the bar.
 15. Verify the **volume widget** (opt-in: add `"volume"` to a zone). With
     a running PipeWire server (pipewire + wireplumber / pipewire-pulse)
     and at least one output sink configured:
     - The widget appears with a device-kind glyph (headphones, speakers,
       monitor, …) and a label of `Vol N%` (where `N` is the active sink's
       level). On a system with PipeWire but no output sink configured,
       the widget reserves no slot (matching the `network` / `system`
       pattern); on a system with no PipeWire running, the widget also
       reserves no slot.
     - Change the volume with `pactl set-sink-volume @DEFAULT_SINK@ 50%`
       (or `wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.5`) and confirm the
       label updates by the next poll (the producer re-emits every two
       seconds when the cached state changes). The widget should track
       per-cent: `12%`, `13%`, …
     - Toggle mute with `pactl set-sink-mute @DEFAULT_SINK@ 1` and confirm
       the label flips to `Mute` with the volume-mute glyph; toggling
       mute back restores `Vol N%` with the device glyph.
     - With multiple sinks configured (e.g. headphones + monitor speakers),
       play audio through one and confirm the widget tracks *that* sink
       (active sink has its `NodeState` set to `Running`); switching
       playback to a different sink updates the widget to that sink
       within a poll, and the glyph follows the device kind (e.g.
       headphones → speakers).
     - If a `[widget.volume] on-click = "/path/to/executable"` is set
       (typical: `pavucontrol`), left-click the widget and confirm the
       configured process is spawned (no shell, direct exec). A missing
       or non-executable path is logged as an error and does not crash
       the bar.
 16. Verify the **notifications widget** (opt-in: add `"notifications"` to a
     zone). With swaync running as the session's notification daemon:
     - The widget appears as a bell glyph with no dot. With swaync not
       running, the widget reserves no slot.
     - Send `notify-send test`; swaync shows its popup and a small dot
       appears in the bell's upper-right corner. Clearing the notification
       (from the popup or the panel) removes the dot.
     - Left-click the bell and confirm the swaync control-center panel
       toggles open/closed.
     - Right-click the bell and confirm Do-Not-Disturb toggles: the bell
       swaps to the slashed glyph and dims; right-clicking again restores
       it. The DND state stays in sync when toggled from inside the swaync
       panel instead.
      - Restart resilience: stopping swaync removes the widget from the bar;
        starting it again brings it back with the current state, without
        restarting tablero. Note that swaync is usually DBus-activatable, so
       on most setups `pkill swaync` resurrects it near-instantly (any bus
       call to its name starts it again) — `systemctl --user mask swaync`
       first (and `unmask` after) to observe the widget-hidden state.
       tablero itself never triggers that activation: its presence probe is
        sent with the `NO_AUTO_START` flag, so it observes the daemon rather
        than restarting one the user stopped.
17. Verify the **Hypridle and power widgets** (opt-in: add `"hypridle"` and
    `"power"` to a zone).
    - Start with Hypridle running. The lock glyph should use the configured
      accent and its tooltip should read `Hypridle active`.
    - Left-click the lock. The process should receive `SIGTERM`, the glyph should
      switch to the configured inactive foreground, and the tooltip should read
      `Hypridle inactive`. Left-click again to start `hypridle` directly.
    - Left-click the power glyph and confirm the configured `on-click` executable
      starts. Right-click it and confirm `on-click-right` starts independently.
      A missing executable should log a warning without terminating tablero.

## License

tablero is licensed under the [GNU General Public License v3.0](LICENSE).