GitHub - pjz/hardbar: compile-time-defined i3-blocks

7 min read Original article ↗

A fast, compile-time-configured status bar for i3 and sway, outputting the i3bar JSON protocol.

The goal is to avoid the fork-and-exec overhead of tools like i3blocks for every update cycle. Modules that read system state (CPU, WiFi, battery, network traffic) are compiled in and run natively. A shell module is available when you need to run an external command, but it is opt-in.

Building

Requires Zig 0.16. A shell.nix is provided that pins the correct version:

nix-shell --run "zig build"

The binary is placed at zig-out/bin/hardbar.

Setup

In your i3 or sway config:

bar {
    status_command /path/to/hardbar
}

Configuration

Configuration lives in config.zon, next to build.zig. It is a ZON expression evaluated at compile time — there is no runtime config file parsing.

Minimal example

.{
    .global = .{
        .separator = " | ",
    },
    .status = .{
        .{
            .module = "cpu",
            .interval = 5,
            .format = .{ "CPU: ", .usage },
        },
        .{
            .module = "datetime",
            .interval = 10,
            .format = "%Y-%m-%d %H:%M",
        },
    },
}

Global settings

Field Default Description
.separator " | " String placed between blocks

Block settings

Every entry in .status supports these common fields:

Field Required Description
.module yes Module name (see below)
.interval no (5s) Seconds between updates
.format no Format string or tuple (module-specific)
.pango no Set true to enable Pango markup in this block
.args no Module-specific arguments struct

Blocks that produce empty output are omitted from the bar entirely. This is useful for modules like battery that should be invisible on desktop machines.

Click handling

Any block can have click/scroll commands. When the user interacts with a block in i3bar, the corresponding shell command is run via /bin/sh -c.

Field Mouse event
.click_left Left click (button 1)
.click Alias for .click_left
.click_middle Middle click (button 2)
.click_right Right click (button 3)
.scroll_up Scroll wheel up (4)
.scroll_down Scroll wheel down (5)
.scroll_left Scroll wheel left (6)
.scroll_right Scroll wheel right (7)
.click_back Back button (8)
.click_forward Forward button (9)

Pango markup

Set .pango = true on a block to enable Pango markup in its output. The block will be sent to i3bar with "markup":"pango". Modules that produce text from external sources (wifi SSID, weather, shell output) XML-escape their output when pango mode is active, so that the literal text is still displayed safely inside any surrounding markup you add to the format string.

.{
    .module = "battery",
    .pango = true,
    .format = .{
        .discharging = .{ "<span color=\"#ff0000\">", .capacity, "%</span>" },
        .charging    = .{ .capacity, "% +" },
    },
},

Modules

cpu

Shows CPU usage as a percentage, averaged across all cores since the last sample. Reads /proc/stat.

Format fields: .usage — integer percentage with % sign (e.g. 42%)

Args: none

Default format: .{.usage}

.{
    .module = "cpu",
    .interval = 5,
    .format = .{ "CPU: ", .usage },
},

The first sample always shows 0% because there is no previous baseline.


datetime

Shows the current local date and time. The format string is passed directly to libc strftime(3).

Format: a single string (not a tuple), passed to strftime

Args: none

Default format: "%Y-%m-%d %H:%M:%S"

.{
    .module = "datetime",
    .interval = 10,
    .format = "%Y-%m-%d %H:%M",
},

Common strftime codes: %Y year, %m month, %d day, %H hour (24h), %M minute, %S second, %A weekday name, %I hour (12h), %p AM/PM.


wifi

Shows the SSID and signal strength of the associated WiFi network. Uses the Generic Netlink / nl80211 interface directly (no external tools needed).

The interface is auto-detected at runtime from /proc/net/wireless on first use and cached for the lifetime of the process. Override with .args if needed.

Format fields:

Field Description
.ssid Network name (XML-escaped if pango)
.signal Signal strength in dBm (e.g. -61)

Args:

Field Default Description
.interface auto-detected WiFi interface name

Default format: .{.ssid}

.{
    .module = "wifi",
    .interval = 5,
    .format = .{ .ssid, " (", .signal, "dBm)" },
},
// Override interface:
.{
    .module = "wifi",
    .interval = 5,
    .args = .{ .interface = "wlan0" },
},

When not associated, the block shows disconnected.


battery

Shows battery capacity and charge status. Reads /sys/class/power_supply/<battery>/.

The block produces empty output (and is hidden) when the battery path cannot be read — so on desktop machines without a battery it disappears automatically.

Format: a struct with per-status sub-formats. Each sub-format is a tuple.

Key When used
.discharging Battery discharging or status unknown
.charging Battery charging
.full Battery full (falls back to .charging if absent)

Format fields (within each sub-format):

Field Description
.capacity Integer percent (0–100)

Args:

Field Default Description
.battery "BAT0" Battery name under /sys/class/power_supply/

Default format:

.{
    .discharging = .{ .capacity, "%" },
    .charging    = .{ .capacity, "% +" },
}
.{
    .module = "battery",
    .interval = 30,
    .pango = true,
    .format = .{
        .discharging = .{ "<span color=\"#ff0000\">", .capacity, "%</span>" },
        .charging    = .{ .capacity, "% +" },
        .full        = .{ .capacity, "% (full)" },
    },
},

nettraffic

Shows network throughput in IEC units (B/s, K/s, M/s, G/s). Reads byte counters from /sys/class/net/<iface>/statistics/. The first interval shows nothing while a baseline is being established.

The interface is auto-detected from the default route in /proc/net/route (excludes ppp*, tun*, tap*).

Format fields:

Field Description
.rx Download rate (e.g. 1.4M)
.tx Upload rate
.total Combined rx + tx rate
.iface Interface name (e.g. eth0)

Args:

Field Default Description
.interface auto-detected Network interface name

Default format: .{ .rx, "↓ ", .tx, "↑" }

.{
    .module = "nettraffic",
    .interval = 5,
},
// Fixed interface with combined rate:
.{
    .module = "nettraffic",
    .interval = 5,
    .format = .{ .iface, " ", .total, " total" },
    .args = .{ .interface = "eth0" },
},

weather

Fetches current weather from wttr.in. Makes an HTTP request each interval. Minimum interval is 60 seconds (enforced at compile time).

Format: not a tuple — the .args.format field is a wttr.in format string passed as a URL parameter. See https://wttr.in/:help for codes.

Args:

Field Default Description
.location "" City name or coordinates (URL-encoded)
.format "%t+%C" wttr.in format string (%t=temp, %C=condition, %h=humidity, %w=wind)

Common format codes: %t temperature, %C condition text, %h humidity, %w wind, %p precipitation, %m moon phase.

.{
    .module = "weather",
    .interval = 1800,
    .args = .{ .location = "New+York", .format = "%t+%C" },
},
.{
    .module = "weather",
    .interval = 900,
    .args = .{ .location = "48.8566,2.3522", .format = "%t %h %w" },
},

pulse

Shows PulseAudio (or PipeWire's PulseAudio compatibility layer) sink volume and mute state. Connects to the PA server directly via libpulse each interval.

Format: a struct with per-state sub-formats. Each sub-format is a tuple.

Key When used
.unmuted Sink is not muted
.muted Sink is muted

Format fields (within each sub-format):

Field Description
.volume Volume as integer percent (0–100)

Args:

Field Default Description
.sink "@DEFAULT_SINK@" Sink name passed to PA

Default format:

.{
    .unmuted = .{ .volume, "%" },
    .muted   = .{ .volume, "% [muted]" },
}
.{
    .module = "pulse",
    .interval = 5,
    .format = .{
        .unmuted = .{ "󰕾 ", .volume, "%" },
        .muted   = .{ "󰖁 ", .volume, "%" },
    },
    .scroll_up    = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
    .scroll_down  = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
    .click_middle = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
},

shell

Runs an arbitrary shell command each interval and displays the first line of its stdout, stripped of leading and trailing whitespace. If the command produces no output the block is hidden.

Format: a single string (not a tuple) — the shell command to run. It is executed via /bin/sh -c. If .pango = true, the output is XML-escaped before being placed in the bar (so you can safely display arbitrary text inside Pango markup).

Args: none

.{
    .module = "shell",
    .interval = 5,
    .format = "cat /proc/loadavg | cut -d' ' -f1-3",
},
.{
    .module = "shell",
    .interval = 30,
    .format = "/home/user/scripts/my-status",
    .click_left = "/home/user/scripts/my-status-click",
},

A module can be used more than once with different .format values.