Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
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:
On Arch Linux:
Then install the latest release from crates.io with the current stable Rust toolchain:
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
Set RUST_LOG=info for startup and lifecycle logging:
RUST_LOG=info
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, withoutbrightnessctlor 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; whenparuis installed, pending AUR updates fromparu -Quaare 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"towidgetsto 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 SNIContextMenufallback. Under a compositor with no nativeStatusNotifierWatcher(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, orunavailablewhen 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 anon-clickconfig field — when set, a left-click inside the widget spawns that executable directly (no shell), so a typical use ison-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 thenetworkandsystemwidgets treat an absent source. Theon-clickfield 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-clientsubprocess). 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.
- Draws through
cosmic-text+tiny-skia, committed via awl_shmARGB8888 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_scalemaps 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
calloopchannel. The PipeWire volume source is the one exception: it runs on a dedicated OS thread (PipeWire'sMainLoopis synchronous and file-descriptor-driven, unlike zbus), and reaches the Tokio runtime the same way — by sendingMsg::Volumes through the cross-threadMsgSender. - Wakes only for clock ticks (a
callooptimer 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.
It demonstrates the full styling surface and is intentionally different from
the built-in defaults documented below:
Reference
Every value below is the built-in default.
# Bar height in logical pixels (scaled to the output's pixel density on HiDPI
# displays). The width always spans the output.
= 32
[]
# Logical-pixel inset around the full module row and gap between adjacent modules.
= 0
= 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", and
# "power-profiles-daemon". Repeats are de-duplicated, keeping first position.
= ["workspaces"]
= ["title"]
= ["clock", "battery", "system", "network", "power-profiles-daemon"]
[]
# Colors are "#rrggbb" hex strings (the leading "#" is optional).
= "#181818" # fill behind every widget
= "#eaeaea" # default text color
= "#eaeaea" # emphasis color (e.g. the active workspace)
[]
# family is unset by default, which uses the system default font. Uncomment to
# pick a specific family:
# family = "JetBrains Mono"
= 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", and "updates". |
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:
[]
= "#292b1d"
= "#f0d77b"
= "#766b36"
= 1
= 2
= 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:
[]
= "{icon} {percent}%"
[]
= "#2df18520"
= "#5ff5a0"
= "#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. Today the bluetooth,
volume, and updates widgets honor the field; other widgets ignore it.
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:
[]
= ["updates", "clock"]
[]
= "{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:
[]
= ["backlight", "battery", "network"]
[]
= "intel_backlight" # optional; auto-selects the largest range when absent
= "{icon} {percent}%"
= ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
= 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:
[]
= "{icon}"
= "Power profile: {profile}\nDriver: {driver}"
= true
[]
= ""
= ""
= ""
= ""
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, …):
[[]]
= "DP-1" # required: the connector to match
= 40 # override just this output's height
[]
= ["clock"] # replace just this output's center zone
[]
= "#88c0d0" # only the accent changes; background/foreground inherit
[]
= 18.0 # larger text on this monitor only
[[]]
= "eDP-1" # the laptop panel: shorter bar, everything else global
= 28
[]
= "#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:
-
From inside a Hyprland session, run
RUST_LOG=info cargo run -p tablero. -
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.
-
Confirm the clock advances once per second and that the text changes exactly on the second boundary (the timer is second-aligned).
-
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.
-
Confirm tiled windows are pushed down by the bar's height and never render under it — this proves the exclusive zone is honored.
-
Confirm the process is idle between updates:
top/htopshould show ~0% CPU fortablerowhen nothing is changing (no busy loop). -
Optionally inspect the surface:
hyprctl layersshould list atableronamespace in thetoplayer on the active output. -
Drop a
~/.config/tablero/config.toml(e.g. changeheightor thewidgetsorder), 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. -
Verify HiDPI scaling on a scaled output. Set a scale on the monitor (e.g.
hyprctl keyword monitor <name>,preferred,auto,2for integer 2×, or,1.5for 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=infologsoutput scale changed to Nxwhen 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.
-
Verify multi-monitor placement with at least two outputs (
hyprctl monitorslists 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 differentheight,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=infologsoutput … added/output … removedand the app must not crash or leave a stale surface (hyprctl layersshould list exactly onetableronamespace per live output).
-
Verify the system tray with at least two real tray-producing apps. Add
"tray"towidgets(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), orKStatusNotifierItemapps. 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
ContextMenucall 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/libdbusmenuapps 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. ItemIsMenuis often omitted. When a validMenupath exists and the property is absent, tablero treats the item as menu-only for AppIndicator compatibility. An explicitfalsepreserves 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 commonhicolorapp/status sizes and/usr/share/pixmaps—index.themeinheritance is not walked, which covers typical bar apps without the full resolver.
-
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
closedhandler, and the process shuts down once the last bar is gone. -
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 at100characters with a trailing…and never overflow the center zone. Closing the last focused window should leave the center blank. -
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, orunavailable. On a desktop with no Bluetooth hardware it should always reserve a slot and readunavailable; on a laptop with the adapter powered off it readsoff. - Toggle the adapter with
bluetoothctl power on/bluetoothctl power offand 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 to1 connected(or higher) and back toonon 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.
- The widget appears with the bluetooth glyph and a label of
-
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%(whereNis the active sink's level). On a system with PipeWire but no output sink configured, the widget reserves no slot (matching thenetwork/systempattern); on a system with no PipeWire running, the widget also reserves no slot. - Change the volume with
pactl set-sink-volume @DEFAULT_SINK@ 50%(orwpctl 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@ 1and confirm the label flips toMutewith the volume-mute glyph; toggling mute back restoresVol 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
NodeStateset toRunning); 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.
- The widget appears with a device-kind glyph (headphones, speakers,
monitor, …) and a label of
-
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 swayncresurrects it near-instantly (any bus call to its name starts it again) —systemctl --user mask swayncfirst (andunmaskafter) to observe the widget-hidden state. tablero itself never triggers that activation: its presence probe is sent with theNO_AUTO_STARTflag, so it observes the daemon rather than restarting one the user stopped.
License
tablero is licensed under the GNU General Public License v3.0.