foxygit / sxbar Log in
commits tags

/README.md ยท 56.91 KB

raw

sxbar

The simple, yet powerful, status bar for Xorg.

๐Ÿ“– Wiki โ€” full config reference with a searchable sidebar and worked examples for every directive (source).

#FORK

Improved Multi-Monitor Workspace Box Handling

This patch enhances sxbar so that workspace boxes on each monitor and only show windows actually present on that monitor โ€” like dwmbar.

Changes

  • Added visual workspace window boxes (max 4)
  • Per-monitor window counting:

    Workspace boxes now only count windows located on the current monitor, instead of showing all windows across all screens.

  • New helper function:

    Added window_on_monitor(Window win, int monitor_index) to determine if a window belongs to a specific monitor.

  • Workspace box logic update:

    The workspace box drawing code now uses this function to filter windows per monitor.

Result

  • Each sxbar instance displays accurate workspace status for its own screen.
  • Workspace boxes are informative and reflect the window layout on each monitor.

Version display support

This update adds optional version information to the bar.

Changes

  • Added new global config keys in default_sxbarc:
    • show_version : true
    • version_text : sxbar ver. 1.1
  • Parser now supports show_version and version_text in the config file.
  • sxbar now draws the version text only when show_version is enabled.
  • Default initialization now enables version display and uses the SXBAR_VERSION string.

Result

  • The bar can show a custom version string at the right edge.
  • Version output is configurable and can be disabled without changing the code.

Config file and script modules

A new config file format is now supported via default_sxbarc.

Highlights

  • Global options can be set in the config file.
  • Custom script modules are supported using custom : label : "command or script" : interval.
  • Bash commands, external script paths and shell pipelines can be used for dynamic status output.

Example

  • custom : temp : "sensors | grep 'Package' | awk '{print $4}'" : 5
  • custom : network : "~/.config/sxbar/scripts/network.sh" : 5

Result

  • Users can configure sxbar without recompiling.
  • Bash scripts and shell commands can produce bar text dynamically.

Clickable modules

Modules in the bar can be left-clicked to run a command.

Changes

  • Added click_command field to the Module struct.
  • Added hdl_button event handler that maps a click's X position to the correct module.
  • Registered the handler for ButtonPress events (the event mask was already set).
  • Commands are launched in the background via double-fork so sxbar never blocks.
  • Added click directive to the config parser.

Config syntax

click : module_name : "command"

Works for both built-in modules (clock, date, battery, volume, cpu) and custom modules.

Examples

click : volume  : "pavucontrol"
click : clock   : "gsimplecal"
click : battery : "xterm -e 'upower -i /org/freedesktop/UPower/devices/battery_BAT0; read'"
click : network : "xterm -e nmtui"

Custom module with a click command:

custom : temp : "sensors | grep 'Package' | awk '{print $4}'" : 5
click  : temp : "xterm -e 'watch -n1 sensors; read'"

Result

  • Left-clicking any enabled module runs its assigned command.
  • The command launches detached in the background โ€” sxbar remains responsive.

Module prefixes / icons

Any module โ€” built-in or custom โ€” can have text prepended to its output, e.g. a Nerd Font glyph.

Changes

  • Added prefix field to the Module struct.
  • Added prefix directive to the config parser (icon is accepted as an alias).
  • Drawing and click-hit-testing now use the module's prefixed text so

    click regions stay aligned with what's rendered.

Config syntax

prefix : module_name : "icon text"

Requires a Nerd Font set via font to render icon glyphs. For custom modules, prefix must be declared after the module's custom line.

Examples

font     : JetBrainsMono Nerd Font:size=10

prefix : battery : "  "
prefix : volume  : "  "
prefix : cpu     : "  "

Result

  • Built-in modules (clock, date, battery, volume, cpu) can show an

    icon without touching their hardcoded command.

  • Custom modules could already embed an icon directly in their command, but

    prefix keeps that separate from the command itself.

Dynamic module prefixes (prefix_cmd)

prefix is static text. prefix_cmd lets a script pick the icon instead, so it can reflect module state โ€” e.g. a battery icon that changes with charge level and charging status, or a volume icon that shows muted.

Changes

  • Added prefix_command and prefix_cached fields to the Module struct.
  • Added prefix_cmd directive to the config parser (icon_cmd is an alias),

    sharing the same quoted-command parsing as click/scroll_up/scroll_down.

  • update_modules() re-runs the prefix command every time the module itself

    refreshes (same refresh_interval), passing the module's freshly fetched output as $1, shell-quoted to avoid injection.

  • module_text() prefers the live prefix_cached output over the static

    prefix when a module has a prefix_cmd.

Config syntax

prefix_cmd : module_name : "command or script path"

The script receives the module's current output (e.g. "83%") as $1. Built-in module commands don't expose extra state like charging or muted beyond that string, so a script that needs it re-checks the system itself (e.g. /sys/class/power_supply/BAT*/status, wpctl get-volume ... | grep MUTED). The script's stdout becomes the prefix verbatim โ€” include your own trailing space if you want one before the value.

Example

~/.config/sxbar/scripts/battery_icon.sh:

#!/bin/sh
pct=$(printf '%s' "$1" | tr -dc '0-9')
pct=${pct:-0}
status=$(cat /sys/class/power_supply/BAT*/status 2>/dev/null | head -n1)

if [ "$status" = "Charging" ]; then
    printf '%s ' ''   # bolt glyph
    exit 0
fi

if   [ "$pct" -ge 90 ]; then printf '%s ' ''   # battery-full
elif [ "$pct" -ge 50 ]; then printf '%s ' ''   # battery-three-quarters
elif [ "$pct" -ge 20 ]; then printf '%s ' ''   # battery-half
elif [ "$pct" -ge 10 ]; then printf '%s ' ''   # battery-quarter
else printf '%s ' ''                            # battery-empty
fi
prefix_cmd : battery : "~/.config/sxbar/scripts/battery_icon.sh"

This and a matching volume_icon.sh live under scripts/ in this repo and are installed by make install to $(PREFIX)/share/sxbar/scripts/ as reference copies โ€” sxbarc still points at your own copy under ~/.config/sxbar/scripts/, which sxbar seeds from those reference copies on first run (see "Auto-seeded ~/.config/sxbar/scripts/" below), so just edit the copy that ends up there rather than the installed ones.

Result

  • Icons can reflect live state instead of being fixed per module.
  • Logic lives in an ordinary shell script, not in sxbar's C source.

Fixed-width modules

Right-aligned layout means every module's on-screen position depends on the total width of everything to its right. Without a fixed width, a module like cpu shifts the entire bar each time its digit count changes (e.g. 9% -> 16% -> 100%).

Changes

  • Added min_width field to the Module struct.
  • Added width directive to the config parser.
  • Added module_slot_width(), used in both draw_bar_into() and

    hdl_button() in place of the raw text width when computing layout positions and click-hit regions โ€” the module's own text is still drawn and measured normally, only the space reserved for it in the layout changes.

Config syntax

width : module_name : min_pixels

The module's text is never truncated โ€” if it's wider than min_width it just uses its natural width, same as before. Pick a value wide enough for the largest expected reading (e.g. the pixel width of "100%" plus its icon, in your configured font/size).

Example

width : battery : 48
width : volume  : 48
width : cpu     : 48

Result

  • Modules with a width set no longer shift their neighbours around when

    their value's digit count changes.

  • Click regions stay aligned with the reserved (not just the rendered) width.

Secondary bar (two bars, one top, one bottom)

A second bar can be enabled on the edge opposite the primary one. It is modules-only: no workspace switcher, no version text โ€” just whichever modules are tagged onto it.

Changes

  • Replaced the parallel per-monitor arrays (wins, buffers, xft_draws)

    with a single Bar array (src/defs.h), each entry holding its monitor index, whether it's the secondary variant, and its own window/pixmap/ Xft draw context. nbars = nmonitors * (secondary_bar ? 2 : 1).

  • create_bars() creates one bar per monitor, or two (opposite edges) per

    monitor when secondary_bar is on. Each bar computes its own geometry and _NET_WM_STRUT_PARTIAL independently, so both reserve their own screen edge correctly.

  • draw_bar_into(), hdl_button() now take a bar index and filter the

    module list by on_secondary; the workspace switcher and version text are skipped entirely for secondary bars.

  • Added on_secondary field to Module and the bar config directive.
  • Renamed find_window_monitor/redraw_monitor to find_bar/redraw_bar

    (they now index into bars, not monitors, since bar count and monitor count differ once a secondary bar exists).

Config syntax

secondary_bar : true

bar : module_name : primary|secondary

bar defaults to primary โ€” only set it for modules moving to the secondary bar. The secondary bar always sits on the edge opposite bottom_bar, and currently shares the primary bar's font/colours/height (no independent per-bar styling yet).

Example

bottom_bar    : false
secondary_bar : true

module : battery : true : 30
module : volume  : true : 5
module : cpu     : true : 3

bar : battery : secondary
bar : volume  : secondary
bar : cpu     : secondary

Clock, date and the workspace switcher stay on the primary bar (top); battery, volume and cpu move to a plain modules-only bar (bottom).

Result

  • Two bar windows, one per screen edge, coexist without clobbering each

    other's strut reservation.

  • Existing single-bar configs are unaffected โ€” secondary_bar defaults to

    off and every module defaults to the primary bar.

Module alignment (left / center / right)

Modules used to always draw as one right-anchored cluster. They can now be split into three independently-anchored groups per bar.

Changes

  • Added align field to Module (ALIGN_LEFT/ALIGN_CENTER/ALIGN_RIGHT

    in src/defs.h; ALIGN_RIGHT is 0 so unset modules keep today's behaviour with no config changes needed).

  • Added align config directive.
  • draw_bar_into()'s single module loop became three: left continues on

    from wherever the workspace switcher ended, center is centered across the full bar width, right is anchored before version_text exactly like before.

  • hdl_button() mirrors the same three groups for click-hit-testing.

    Added workspace_end_x() so it can compute the left group's start position without duplicating the workspace-drawing loop.

Config syntax

align : module_name : left|center|right

Example

align : clock : left
align : date  : left
align : cpu   : center

battery/volume (no align set) stay in the default right-anchored group.

Result

  • Modules can be grouped into left/center/right clusters, e.g. workspaces

    and clock on the left, a custom module centered, system stats on the right.

  • Existing configs are unaffected โ€” no align lines means every module

    stays in the right cluster, identical to the old single-group layout.

Bug fix found along the way

Testing this against a real config with colour : module_name : #hex lines turned up a pre-existing parser bug: strip_comment() truncated at the first # in a value, so a hex colour like #50fa7b โ€” which starts with # โ€” was stripped down to an empty string before strdup, silently making every coloured module invisible (XftColorAllocName was also not checked, so a failed allocation gave up transparent text). Fixed to only treat a # as a comment marker when it's not the first character.

Known limitation, not fixed: background_colour, foreground_colour and border_colour are parsed with parse_col(rest) directly and never call strip_comment() at all (unlike the per-module colour directive, font, version_text, etc.) โ€” confirmed correct for plain values via pixel sampling (#000000/#7abccd rendered exact), but a trailing # comment on one of those three lines would get passed straight into XParseColor and fail (falls back to white, with a stderr warning). Comment on its own line instead for now.

Floating popups: brightness slider, bluetooth menu, user menu

Three built-in modules can now open a small floating (override-redirect) window instead of just running a click command: brightness reveals a slider on hover, bluetooth and usermenu open a button menu on click.

Changes

  • Added a generic popup subsystem: at most one floating window open at a

    time, drawn with the bar's own font/colours. Added Popup (src/defs.h) holding its runtime state, and PopupItem for button-menu rows.

  • Added popup_type (POPUP_BUTTONS/POPUP_SLIDER), popup_trigger

    (POPUP_TRIGGER_HOVER/POPUP_TRIGGER_CLICK), a popup_items array and popup_set_command to Module.

  • Added PointerMotionMask/LeaveWindowMask to the bar windows and new

    event handlers -- hdl_motion, hdl_crossing, hdl_button_release -- registered alongside the existing hdl_button/hdl_expose.

  • Hovering a hover-triggered module opens its popup; moving the pointer

    off both the module and the popup closes it. Clicking a click-triggered module toggles its popup; clicking a button row runs that row's command and closes it; clicking anywhere else (including other applications -- caught via a pointer grab held while the popup is open) also closes it, like an ordinary dropdown menu.

  • The slider drags via the same pointer grab (PointerMotionMask +

    ButtonReleaseMask), spawning popup_set_command with the new value (as $1, e.g. "45%") only when the value actually changes.

  • Three new built-in modules, all opt-in (enabled : false by default,

    except usermenu):

    • brightness -- reads brightnessctl -m; hover slider drags call

      brightnessctl set.

    • bluetooth -- shows adapter power state via bluetoothctl show;

      click menu has Turn on/off, Search for devices, and Pair last found device (bluetoothctl pair/trust/connect on the most recently seen device -- no interactive device list).

    • usermenu -- shows the current username (whoami); click menu has

      Sleep (systemctl suspend), Log out, and Shut down (systemctl poweroff). The default logout command is pkill sxwm, since minimal WMs without a session manager have no external way to trigger their own quit keybind -- override via popup_item in sxbarc if your WM/session needs something else.

Config syntax

popup      : module_name : hover|click : buttons|slider
popup_item : module_name : "Label" : "command"      # buttons, repeatable
popup_set  : module_name : "command"                # slider only, $1 = new value

popup_item is additive after the first line for a module clears its built-in defaults, so a config can fully replace bluetooth's or usermenu's menu, or extend a custom module with its own button popup.

Example

module : brightness : true : 5
module : bluetooth  : true : 10

popup_item : usermenu : "Lock" : "slock"   # replaces the default 3 rows
popup_item : usermenu : "Sleep" : "systemctl suspend"
popup_item : usermenu : "Log out" : "pkill sxwm"
popup_item : usermenu : "Shut down" : "systemctl poweroff"

Result

  • No new build dependencies -- the popups are plain Xlib windows sharing

    the bar's existing Xft font/colour setup.

  • brightnessctl/bluetoothctl are only needed at runtime by the modules

    that shell out to them, same as wpctl/playerctl for other modules.

Volume slider and a network popup with WiFi/Ethernet + IPs

Extends the floating-popup work above with a second slider (volume, alongside brightness) and a new network module whose popup shows live connection info instead of running actions.

Changes

  • volume now opens the same hover slider as brightness -- dragging it

    spawns wpctl set-volume @DEFAULT_AUDIO_SINK@ NN%.

  • Added PopupItem.label_command (src/defs.h): when set, a buttons-popup

    row's label is re-run fresh (run_command()) every time the popup opens instead of using a fixed string, and clicking the row just closes the popup (no action) -- for informational rows like "current IP".

  • Added the popup_info : module_name : "shell command" config directive

    (src/parser.c), the config-side counterpart to label_command. It shares the same "first line clears the built-in defaults, later lines append" rule as popup_item, via a clear_builtin_popup_items() helper both directives now go through.

  • New built-in network module: bar text is the default route's interface

    name (or "Offline"); its click popup has two informational rows -- first interface with a /sys/class/net/<if>/wireless directory for WiFi, first non-virtual ARPHRD_ETHER interface for Ethernet -- each showing its IPv4 address (ip -4 -o addr show) or "disconnected" if it has none. No interactive network switching, just a status readout.

Config syntax

popup_info : module_name : "shell command"   # label = command's output, re-run on every open

Example

module : volume  : true : 5
module : network : true : 10

popup_info : network : "curl -s ifconfig.me | awk '{print \"Public IP: \"$0}'"

icon_only, and a hover popup on cpu

Two small additions: a way to hide a module's own text and show just its icon, and proof that a buttons popup works on hover just as well as slider does (previously only shown paired with click).

Changes

  • Added Module.icon_only (src/defs.h) and the `icon_only : module_name

    : true|false config directive. module_text() now returns just the prefix (or ""` if none set) when it's on, instead of prefix+output -- the module's command, refresh_interval and popup all keep running exactly as before, only the rendered bar text changes.

  • cpu now opens a buttons+hover popup with three informational rows

    (popup_info, see previous section) -- a fresh CPU-usage sample, free -h's memory line, and a per-core load breakdown (two /proc/stat samples paired up by position via an awk array, since /bin/sh may be dash and can't do process substitution) -- demonstrating that popup_type/popup_trigger are independent axes: any combination of buttons/slider with hover/click is valid, not just the pairings the other built-ins happen to use.

Config syntax

icon_only : module_name : true|false

Example

prefix    : network : " "
icon_only : network : true   # bar shows just the icon; click still opens the popup

Row-based popups (text/button/slider mixed freely), and hover by default

Popups were previously one whole type per module -- a module was either a buttons list or a slider, never both, and every row in a buttons popup was clickable (even the "informational" popup_info ones, which just closed the popup for no reason on click). This reworks popups to be a list of independently-typed rows instead, and switches every built-in popup to open on hover.

Changes

  • PopupItem (src/defs.h) now carries its own .type --

    POPUP_ROW_TEXT, POPUP_ROW_BUTTON, or POPUP_ROW_SLIDER -- instead of the whole popup having one Module.popup_type. popup_type on Module now only means "this module has a popup at all"; POPUP_SLIDER as a per-module type is gone.

  • popup_info rows are now genuinely inert: clicking one does nothing at

    all (previously it closed the popup, which read as a fake button). popup_item/popup_info/popup_set can all target the same module and coexist in one popup -- popup_set manages a single dedicated slider row per module (tracked via Module.slider_item_idx), independent of whatever text/button rows also exist there.

  • Popup geometry (popup_row_height/popup_item_height/

    popup_total_height/popup_row_y/popup_row_at_y) is now computed per-row instead of assuming a uniform row height or a single slider filling the whole window, since a slider row is taller than a text/button row. Dragging (Popup.dragging_row) and hover-highlighting (Popup.hover_row, buttons only) are tracked by row index rather than assuming the whole popup is one slider or one button list.

  • bluetooth, usermenu and network switched from click to hover

    triggers, matching brightness/volume/cpu -- every built-in popup now opens the same way. Hovering only reveals the menu; an actual click on a button row is still needed to run anything, so this doesn't make destructive actions (shutdown, logout) any easier to trigger by accident. Override back to click per-module with popup : module_name : click : buttons if you'd rather.

  • demo_menu (the try-it-yourself example) now combines all three row

    kinds in one popup instead of being split across demo_menu (buttons) and a separate demo_slider.

Config syntax

Unchanged -- popup_item/popup_info/popup_set already existed; what's new is that they can all apply to the same module at once:

popup      : module_name : hover|click : buttons|slider   # third field just means "has a popup"
popup_info : module_name : "shell command"                 # text row
popup_item : module_name : "Label" : "command"              # button row
popup_set  : module_name : "command"                        # slider row

Example

custom     : mymodule : "echo ok" : 5
popup      : mymodule : hover : buttons
popup_info : mymodule : "echo 'status: '$(whoami)"
popup_item : mymodule : "Restart" : "systemctl --user restart myservice"
popup_set  : mymodule : "myservice-set-level"

Built-in module logic moved into scripts/, and popups on clock/date/battery

Every built-in module's shell command used to be a string literal embedded in init_modules() -- some of them (cpu's /proc/stat sampling, network's interface detection) fairly long, gnarly awk/shell pipelines. This moves all of that logic into scripts/, one script per module, and adds popups to the three modules that didn't have one yet (clock, date, battery).

Changes

  • Added resolve_script(name) and script_cmd(name, extra_arg) (src/sxbar.c).

    resolve_script checks, in order: the user's own edited copy at ~/.config/sxbar/scripts/<name>.sh, then the system copy make install places at /usr/local/share/sxbar/scripts/<name>.sh (same fallback-path convention as the config-file lookup in parser.c), or a harmless shell no-op (:) if neither exists -- e.g. a freshly built, not-yet-installed checkout. script_cmd appends a fixed (not user-controlled) extra argument, used to reuse one script for two purposes -- see cpu below.

  • Every built-in module's .command, and every non-trivial popup row or

    slider set_command, now resolves to a script instead of an embedded string. Trivial single-command actions (bluetoothctl power on, systemctl suspend, etc.) stay inline -- there's no logic in those worth hiding in a file.

  • Modules whose bar text and popup content are closely related share one

    script with subcommands, rather than one file per piece of output:

    • cpu.sh -- usage [PREFIX] (bar text / usage popup row), mem,

      cores

    • network.sh -- status (bar text, default), wifi, ethernet
    • battery.sh -- capacity (bar text, default), status,

      toggle-powersave

    • volume.sh / brightness.sh -- get (bar text/slider start,

      default), set VALUE

    • bluetooth.sh -- status (bar text, default), pair
  • New popups, matching the pattern the other six built-ins already use

    (hover, no accidental triggering -- a click on a row is still required):

    • clock / date -- "Open calendar" button (gsimplecal)
    • battery -- a detailed status row (upower: state, time

      remaining, health) plus a "Toggle power saver" button (power-profiles-daemon), both opt-in extra tools beyond the /sys/class/power_supply read the bar text itself uses

Result

  • scripts/ now has exactly one file per built-in module (plus

    battery_icon.sh/volume_icon.sh for prefix_cmd icons and demo_popup.sh for the try-it-yourself popup) -- copy any of them to ~/.config/sxbar/scripts/ and edit freely, same as the existing prefix_cmd convention, without recompiling.

  • All nine built-in modules now have a popup.

Module-declared menus -- each built-in module's popup now lives in its own script

The previous section moved bar-text/popup commands into scripts/, but each module's popup content (which rows it has, in what order) was still hardcoded in init_modules() (src/sxbar.c) -- e.g. usermenu's Sleep/Log out/Shut down rows were three add_popup_item() calls at compile time, only reachable from sxbarc by overriding them wholesale via popup_item. This moves that content into the scripts themselves, so a built-in module's entire on-bar behaviour -- bar text and popup both -- lives in one self-contained file, and sxbarc goes back to just turning modules on/off and tuning their refresh interval, as intended.

Changes

  • Every built-in module's script now supports a menu subcommand (e.g.

    usermenu.sh menu). Run once at startup, it prints that module's popup definition using the exact same popup/popup_item/popup_info/ popup_set directives sxbarc itself uses, just without the module-name field -- a script only ever describes itself. Example (scripts/usermenu.sh menu):

  popup : hover : buttons
  popup_item : "Sleep" : "systemctl suspend"
  popup_item : "Log out" : "pkill sxwm"
  popup_item : "Shut down" : "systemctl poweroff"
  
  • Added load_popup_from_script(Module *m, const char *script_path)

    (src/parser.c, declared in parser.h): runs <script_path> menu and feeds each line through the same directive parsing sxbarc's own popup/popup_item/popup_info/popup_set lines use.

  • Refactored that parsing itself: the four directives' bodies (previously

    duplicated between parse_config()'s sxbarc-line handling and init_modules()'s compile-time-default helpers) are now the single apply_popup()/apply_popup_item()/apply_popup_info()/ apply_popup_set() functions in parser.c, shared by both callers.

  • init_modules() (src/sxbar.c) shrank from one hardcoded struct

    literal plus manual add_popup_item/add_popup_info_item/ add_popup_slider_item calls per module, to a single add_builtin_module(name, enabled, refresh_interval) helper that resolves the script and calls load_popup_from_script. The old add_popup_*/script_cmd helpers are gone; nothing else used them.

Result

  • Adding a menu item to usermenu (or any other built-in module's popup)

    is now a one-line edit to your own copy of that module's script under ~/.config/sxbar/scripts/ -- no sxbarc editing, no recompiling.

  • sxbarc's popup/popup_item/popup_info/popup_set directives are

    unchanged and still work exactly as before: the first popup_item/ popup_info line for a module still replaces its default rows (now script-provided instead of compiled-in) and later lines still append, so sxbarc can still override a script's menu wholesale if you'd rather keep everything in one config file.

  • A custom module (custom : name : "cmd" : interval) has no script to

    load a menu from, so this is a no-op for those -- give it a popup via sxbarc's directives as before.

Every popup row so far was text, a button or a slider, and every button row took the popup's full width -- nothing could render an actual image, and three related actions (previous/play-pause/next) meant three stacked full-width rows. This adds two new row types for that, and a new built-in media module (MPRIS controls via playerctl) to use both: its hover popup shows the current track's album art, track info, and glyph Previous/Play-Pause/Next buttons side by side on one row.

Changes

  • No new runtime dependency. Image decoding uses

    stb_image.h (src/stb_image.h, public domain, vendored verbatim), compiled straight into the sxbar binary via #define STB_IMAGE_IMPLEMENTATION in src/sxbar.c -- unlike a full image-loading library (Imlib2 and similar), there's no extra shared library or format-loader plugins installed on the system, and no new -dev package needed to build. The only new link flag is -lm (Makefile), for the pow/ldexp calls stb_image's decoders use.

  • New POPUP_ROW_IMAGE row type (src/defs.h). PopupItem gained

    image_command (a shell command, re-run every popup open just like label_command -- its stdout is a path to a local image file, or empty for "no image this time"), plus image (an XImage *, opaque as void * in defs.h so parser.c only ever touches image_command, a plain string) and image_w/image_h (its current on-screen size).

  • New popup_image : module_name : "command" config directive

    (src/parser.c), parsed the same way as popup_info -- including a menu-subcommand form (popup_image : "command", no name field) for module scripts to use, and the same "first popup_item/popup_info/ popup_image line for a module clears its default rows" rule.

  • popup_item_height()/popup_open()/popup_draw() (src/sxbar.c)

    handle the new row type. On open: the image row's command is re-run, decoded via stbi_load(), and (if larger) downscaled to fit within a POPUP_IMAGE_SIZE (160px) square, preserving aspect ratio, via a hand-written box filter (scale_image_rgba() -- averages every source pixel under each destination pixel, so shrinking a photo-sized image down doesn't alias the way nearest-neighbour sampling would). The result is packed into an XImage (rgba_to_ximage()), reading the actual default visual's red/green/blue masks (mask_shift()/mask_bits()) rather than assuming 24-bit TrueColor, so it renders correctly on any visual depth. On draw: XPutImage() blits it onto the popup's pixmap, centred, before the final XCopyArea blits everything to the window, same as every other row. The previous XImage is freed (XDestroyImage()) before loading a new one on each open, and on program exit via cleanup_modules().

  • New POPUP_ROW_BUTTONS row type (src/defs.h) for a row split into N

    equal-width button segments side by side, instead of N stacked full-width POPUP_ROW_BUTTON rows. PopupItem gained buttons/ button_count -- an array of PopupButton { label, command } pairs; labels are static (no per-segment label_command, unlike TEXT rows).

  • New popup_buttons : module_name : "Label1" : "cmd1" : "Label2" : "cmd2" ...

    config directive (src/parser.c, apply_popup_buttons()) -- an even number of quoted label/command pairs, parsed the same quote-then-:-then-quote way as the other directives (so a label or command can itself contain a literal :). Same menu-subcommand form and "first line clears default rows" rule as the other row directives.

  • popup_open()/popup_draw()/popup_handle_button()/hdl_motion()

    (src/sxbar.c) handle the new row type: width is split evenly across button_count segments (the last segment absorbs any leftover pixels from integer division); click/hover hit-testing maps the pointer's X position to a segment index (popup.hover_col, alongside the existing popup.hover_row) instead of the whole row.

  • New scripts/media.sh, added as a tenth built-in module (media,

    disabled by default): dispatches on subcommand, same convention as the other module scripts --

    • bar text (default): a play/pause glyph plus "Artist - Title" via

      playerctl metadata/status; empty when no player is active.

    • track -- "Artist - Title" undecorated, for a popup_info row.
    • art -- resolves playerctl metadata mpris:artUrl: a file:// URL

      is used directly, an http(s):// one is downloaded once and cached under $XDG_CACHE_HOME/sxbar-media-art/<hash-of-url> (needs curl; silently produces no output if curl isn't installed, or the URL isn't reachable) -- for a popup_image row.

    • prev/playpause/next -- playerctl previous/play-pause/next,

      spawned from the popup's step-backward/play/step-forward button segments. These are Nerd Font glyphs (Font Awesome's step_backward/ play/step_forward, U+F048/U+F04B/U+F051) -- an earlier draft used the plain Unicode media-control symbols (โฎ/โฏ/โญ) instead, on the theory that they'd render without needing a patched font, but they turned out to be missing from at least one real Nerd Font (JetBrainsMono Nerd Font's own charset doesn't include that Unicode block, confirmed via fc-query), while every Nerd Font is guaranteed to cover its own Font Awesome icon set -- so Nerd Font glyphs are the safer default given every other module's icon already assumes one.

    • menu -- declares the popup (see below).
  • default_sxbarc: added media to the built-in module list, a

    popup_image/popup_info/popup_buttons override example, and a note on the older four-custom-module playerctl recipe pointing at this instead.

scripts/media.sh menu output

popup : hover : buttons
popup_image : "<path>/media.sh art"
popup_info : "<path>/media.sh track"
popup_buttons : "<step-backward>" : "<path>/media.sh prev" : "<play>" : "<path>/media.sh playpause" : "<step-forward>" : "<path>/media.sh next"

(the <...> labels above are the actual Nerd Font glyphs -- U+F048/ U+F04B/U+F051 -- shown as placeholders here since they render as tofu in plain-text contexts without that font)

Result

  • module : media : true : 2 gets album art, track info and transport

    controls in one hover popup, with zero sxbarc configuration beyond turning the module on -- consistent with every other built-in module's self-contained-script model.

  • Build requirements are unchanged: still just Xlib/Xinerama/Xft (plus

    libm, already present on any system with a C toolchain) -- no new package to install to build or package sxbar.

  • Any other module -- built-in or custom -- can add its own image row via

    popup_image, or its own segmented button row via popup_buttons (e.g. a window/tag switcher, or paging controls), in sxbarc the same way.

Scrolling (marquee) text for unpredictable-length modules

width (see "Fixed-width modules") reserves a minimum slot so short text doesn't cause shifting, but had no answer for the opposite problem: a module whose text can occasionally run very long (media's "Artist - Title", for a long title) would just stretch the bar and shove everything after it sideways. This adds the opposite of width: a hard cap that overflowing text scrolls within instead.

Changes

  • Module (src/defs.h) gained max_width (0 = no cap, same convention

    as min_width) and scroll_offset (the marquee's current scroll position in pixels, persisted on the module so it keeps animating smoothly across redraws).

  • New max_width : module_name : max_pixels config directive

    (src/parser.c), parsed identically to the existing width directive.

  • module_slot_width() (src/sxbar.c) now caps a module's reserved slot

    at max_width when set and the text overflows it, instead of growing to fit -- this alone is what stops the bar from stretching; every caller (layout, click hit-testing in module_at_x()) already goes through this one function, so nothing else needed to change to keep layout and clicks consistent.

  • New draw_module_text() (src/sxbar.c) replaces the plain

    XftDrawStringUtf8() call at all three module-drawing sites (left/center/right groups) in draw_bar_into(). Text that fits draws exactly as before; text that overflows is clipped to max_width via XftDrawSetClipRectangles() and drawn twice back to back ("text " repeated) offset by scroll_offset, so the wrap-around reads as one continuous ticker instead of jumping at the seam.

  • New advance_marquees() (src/sxbar.c): each call, advances

    scroll_offset by a fixed step for every enabled module currently overflowing its max_width, and reports whether any did.

  • run()'s main loop now calls advance_marquees() every ~100ms tick and

    redraws immediately if it reports anything scrolling, independent of the normal once-a-second update_modules()/redraw cadence -- a config that never sets max_width sees zero behaviour change (the function's a no-op, advance_marquees() always returns false), and only modules actually mid-scroll get the faster redraw.

Result

  • max_width : media : 220 (now in the default config's media example)

    keeps a long "Artist - Title" scrolling smoothly inside a fixed-width slot instead of pushing every module after it sideways every time the track changes.

  • Applies to any module, built-in or custom -- e.g. a long window title

    or commit message in a custom module's output.

  • No extra idle cost: sxbar only wakes up to redraw faster than once a

    second while text is actually overflowing somewhere.

The bar-level marquee above only covered a module's own text; a popup's rows had no equivalent, so a module like media with a long track title in its popup_info row would stretch the popup wider than its album art instead of scrolling -- the opposite of what album art is supposed to anchor. This extends the same marquee mechanism to popup rows, and bumps POPUP_IMAGE_SIZE up (120px -> 160px) since the art was the main reason to open the popup in the first place.

Changes

  • PopupItem (src/defs.h) gained scroll_offset, the same idea as a

    Module's but scoped to one popup row.

  • Factored the bar's marquee-drawing logic out into a shared

    draw_ticker(d, col, x, text_y, max_w, text, tw, offset) (src/sxbar.c) -- draw_module_text() (bar) is now a thin wrapper around it, and popup rows call it directly with the popup's own width/offset.

  • popup_open() now first checks whether the module has an IMAGE,

    SLIDER or BUTTONS row (an "anchor") -- if so, plain TEXT/BUTTON rows no longer get to stretch the popup's width to fit their own text; if not (e.g. network's or cpu's popups, which are plain informational rows with nothing to anchor to), nothing changes, same as before.

  • popup_draw()'s TEXT/BUTTON row rendering goes through draw_ticker()

    now instead of a plain XftDrawStringUtf8() call, clipped to the popup's actual width -- a no-op when the text already fits (which, absent an anchor row, it always does by construction).

  • New advance_popup_marquee(), the popup-scoped equivalent of

    advance_marquees(): advances scroll_offset for any row of the currently open popup that overflows, redraws the popup directly if so (it's its own window, separate from the bars), and is called from run()'s existing ~100ms tick.

  • Each row's scroll_offset resets to 0 in popup_open(), so a marquee

    always restarts from the beginning on a fresh hover rather than picking up wherever it left off last time.

Result

  • media's popup is now exactly as wide as its album art (or the

    transport-buttons row, whichever is wider); a long "Artist - Title" scrolls within that width instead of widening the popup past the art.

  • Every other built-in popup (network, cpu, battery, ...) is

    unaffected -- none of them has an IMAGE/SLIDER/BUTTONS row, so they keep sizing to fit their text exactly as before.

Workspace icons

The workspace switcher draws whatever text the window manager reports via _NET_DESKTOP_NAMES -- for most WMs (including sxwm) that's just plain numbers, "1", "2", "3", .... There was no way to show something else there (e.g. Nerd Font glyphs) without renaming the actual desktops in the WM's own config, which most WMs don't even support past plain strings.

Changes

  • New WorkspaceIcon { name, icon } struct and workspace_icons/

    workspace_icon_count/workspace_icon_max fields on Config (src/defs.h).

  • New workspace_icon : name : "icon text" config directive

    (src/parser.c), parsed the same quote-delimited way as prefix; repeatable, growable array, same realloc-doubling pattern as grow_popup_items().

  • New workspace_display_name(name) (src/sxbar.c): looks name (the

    WM-reported desktop name) up against the configured list and returns its replacement text if there's a match, else name unchanged.

  • The three places that format a workspace's label into " %s " --

    draw_bar_into()'s width-measuring pass, its drawing pass, and workspace_end_x() (which mirrors the same layout math for click hit-testing) -- all go through this lookup now instead of using name directly.

  • New ink_centered_x(origin_x, box_w, lead_w, inner) (src/sxbar.c).

    Some Nerd Font icon glyphs -- confirmed via XftTextExtentsUtf8, e.g. JetBrainsMono Nerd Font's (non-Mono) globe/terminal/code icons all report just an 8px advance width while their actual ink is 13-16px wide and entirely right-shifted (0px left bearing, up to -8px "right bearing") -- would otherwise render visibly off-center inside the workspace pill's highlight box, which is sized from that same narrow advance width. A first version of this measured the extents of the whole " %s "-padded label string directly and computed a shift from that -- which, for this same font, turned out to always come back as a no-op (XftTextExtentsUtf8() reported the padded string's bounding box as spanning its entire advance width regardless of where the inner glyph's ink actually sat, silently zeroing out the correction for exactly the glyphs that needed it most). Fixed by measuring inner (the workspace name/icon alone, correctly asymmetric) instead, and offsetting by the known width of the one leading padding space, rather than trying to read the padded string's own (misleading) extents. Verified this shifts each glyph's ink to within half a pixel of the pill's true center (code: ink centered exactly; 1: 12.5 vs. a 12px middle). Leaves the padded string's advance width itself (and therefore layout/ box sizing elsewhere) untouched -- only where within it we draw shifts. Both of the workspace label's XftDrawStringUtf8() calls in draw_bar_into() go through it now. A font's "Nerd Font Mono" variant (if it has one) narrows the underlying advance/ink mismatch a lot on its own (down to ~9px ink vs. 8px advance, for the same JetBrainsMono example) but this fixes it in sxbar itself regardless of which variant is configured.

Result

  • workspace_icon : 1 : "<glyph>" replaces "1"'s pill text with any

    glyph from your Nerd Font, purely cosmetically -- switching still targets the same underlying desktop number, only what's drawn changes.

  • Workspaces with no matching workspace_icon line keep showing their

    plain name exactly as before -- this is opt-in per workspace.

  • Icon glyphs render centered in their pill even when the font's own

    glyph metrics are asymmetric.

Taskbar

Every module so far renders one line of its own text. This adds a module that doesn't: taskbar shows one clickable segment per window on the current workspace, so you can switch focus between them -- the motivating case being a window manager's monocle mode, where only one window is visible at a time and there was otherwise no way to reach the others from the bar. Typical setup is its own row on the secondary bar, directly beneath the primary status bar.

A dependency this actually needed patching a window manager for

wmctrl -i -a <id> (window activation) works by sending a standard EWMH _NET_ACTIVE_WINDOW client message to the root window. Checking sxwm's own source (not just its advertised _NET_SUPPORTED list, which does list _NET_ACTIVE_WINDOW) showed hdl_client_msg() only ever handled _NET_CURRENT_DESKTOP and _NET_WM_STATE -- _NET_ACTIVE_WINDOW was published read-only (to reflect sxwm's own idea of focus) but nothing accepted external requests to change it, and sxwm has no other IPC (no socket, no FIFO, no signals) that could do it either. So this module needed a small sxwm patch first: patches/net-active-window-mrjensk.patch in the sxwm repo (also copied to ~/dotfiles/net-active-window.patch, applied by install.sh right after the existing cursor-dpi-fix.patch) adds a _NET_ACTIVE_WINDOW branch to hdl_client_msg() that resolves the target window to its Client the same way the existing _NET_WM_STATE handler does (find_client(find_toplevel(w))), switches to that client's workspace first if needed (change_workspace()), and focuses/raises it via the existing set_input_focus() -- the same function focus_next()/ focus_prev() already use, so monocle-mode raising and cursor warping behave identically to switching focus with a keybind. Verified the patch applies cleanly (byte-identical result) against a pristine checkout, and together with cursor-dpi-fix.patch in the order install.sh uses.

Changes

  • New scripts/taskbar.sh. Unlike every other built-in module's script,

    its default/list output isn't bar text -- it's an internal wire format, one line per current-workspace window: "Title" : "0xID" : "command", sourced from wmctrl -l filtered to whichever desktop wmctrl -d marks current. focus ID runs wmctrl -i -a ID. menu is an explicit no-op (exit 0): taskbar has no popup, so there's nothing to declare, and without an explicit case it would otherwise fall through to the listing logic.

  • New TaskbarEntry { label, command, id } struct and taskbar_entries/

    taskbar_entry_count fields on Module (src/defs.h). taskbar is registered as an 11th built-in module (add_builtin_module, src/sxbar.c) the same way as any other.

  • New update_taskbar() (src/sxbar.c), called from update_modules()

    instead of the normal run-command-into-cached_output path when m->name is "taskbar": runs the script with list appended and parses each line via a small dedicated parse_three_quoted() (this is an internal wire format, not a user-facing sxbarc directive, so it doesn't reuse parser.c's quote-parsing). Deliberately leaves m->cached_output NULL -- every generic per-module code path (draw_bar_into()'s layout/rendering loops, module_at_x(), advance_marquees()) already skips modules with no cached_output, which is exactly what's wanted: taskbar renders and handles clicks through its own dedicated paths instead.

  • New dedicated block in draw_bar_into() (src/sxbar.c): finds the

    taskbar module, computes the gap between wherever the left-aligned and right-aligned module groups end (reusing total_left/total_right already computed for those groups), and splits that gap into one equal-width segment per window entry. Reads _NET_ACTIVE_WINDOW fresh each draw and highlights the matching entry's segment the same way the active workspace pill is highlighted. Ignores the module's own align -- a fill module has no single anchor side.

  • New taskbar_entry_at_x() (src/sxbar.c), mirroring that block's

    bounds/segment math for click hit-testing without any of the drawing work -- same pattern module_at_x()/workspace_end_x() already use for ordinary modules and the workspace switcher. Wired into hdl_button(): when module_at_x() finds nothing (which it never will for taskbar, since its cached_output is NULL), a left click falls through to this instead, spawning the matched entry's command.

Result

  • module : taskbar : true : 1 plus bar : taskbar : secondary (with

    secondary_bar : true) gets a clickable per-window taskbar on its own row, independent of the primary status bar.

  • Requires wmctrl, and (for the click to actually do anything) a WM

    that honours _NET_ACTIVE_WINDOW requests -- for sxwm specifically, that means the patch above.

Two more self-contained module scripts: a start menu and an every-row-type demo

Two more scripts joining the built-in set, both using the <script> menu-declares-its-own-popup convention scripts/usermenu.sh already established, rather than needing any popup content in sxbarc.

Changes

  • New scripts/startmenu.sh: bar text is a static "Start" label; its

    menu subcommand declares a hover popup of favorite apps/scripts as button rows (Terminal, File manager, Web browser, Lock screen by default). Adding, removing or reordering favorites is a one-line edit to the script's own popup_item lines -- no sxbarc editing needed, same as editing usermenu.sh's Sleep/Log out/Shut down rows.

  • New scripts/demo_menu.sh: a runnable reference combining one of every

    popup row type in a single popup -- popup_info (plain text), popup_image (picks whatever icon it can find under /usr/share/pixmaps/ or /usr/share/icons/hicolor/48x48/apps/, empty if none, same "no output" convention as media.sh art), popup_item, popup_set (slider), and popup_buttons (three segments). The button/slider rows fire a desktop notification via scripts/demo_popup.sh (already shipped, previously wired up as three separate sxbarc directives) so all five row types can be exercised safely before pointing a real module's popup at anything.

Result

  • module : startmenu : true : 3600 is a working start menu.
  • module : demo_menu : true : 999 is a hands-on reference for every

    popup row type sxbar supports, in one place.

No more built-in vs. custom modules -- every module resolves from scripts/

Built-in modules (the fixed list add_builtin_module() registered in init_modules(), src/sxbar.c) and custom modules (the custom : name : "command" : interval directive, src/parser.c) worked differently: built-ins resolved to a script by name and could self-declare a popup via <script> menu; custom modules ran an arbitrary quoted command and (until this change) had no such mechanism. Not a distinction worth keeping -- scripts/startmenu.sh/demo_menu.sh above are exactly as legitimate a module as clock.sh, just not pre-registered in the binary. This removes the split entirely: one module : directive, one resolution mechanism, for every module.

Changes

  • resolve_script(name) -- checks ~/.config/sxbar/scripts/<name>.sh,

    then $PREFIX/share/sxbar/scripts/<name>.sh, else a no-op : -- moved from sxbar.c to parser.c (static, same behaviour) so the module directive's parsing can call it directly.

  • module : name : enabled : interval (src/parser.c) now creates the

    module on the spot if name isn't already registered, instead of erroring unknown builtin module: resolves the script via resolve_script(), then self-declares its popup via load_popup_from_script() -- the exact sequence add_builtin_module() used to run at startup for the fixed list, now run lazily for whichever names sxbarc actually mentions.

  • The custom directive is gone. A one-off shell pipeline that used to be

    a custom line now needs an actual script file, e.g. ~/.config/sxbar/scripts/temp.sh containing sensors | grep 'Package' | awk '{print $4}', enabled with module : temp : true : 5 like anything else. An old config's custom : lines are now an unrecognized directive (a harmless per-line warning, config parsing continues).

  • add_builtin_module() and the eleven add_builtin_module("clock", ...)

    etc. calls are gone from init_modules() (src/sxbar.c), which now just allocates the (still growable) config.modules array and returns -- every module, including the ones sxbar ships a script for, is created entirely from sxbarc's module : lines via the mechanism above. default_sxbarc is unchanged in what it enables by default, since it already had a module : line for each of them.

Result

  • Writing your own module is now genuinely identical to using a shipped

    one: put a script at ~/.config/sxbar/scripts/<name>.sh, add module : <name> : true : <interval> to sxbarc. No custom syntax to learn, no separate mental model for "mine" vs. "sxbar's".

  • A config with no module : lines at all now starts with zero modules

    (previously the eleven built-ins would still be pre-registered, disabled/enabled per their compiled-in defaults, even with an empty or missing sxbarc) -- config now fully determines what exists, not just what's turned on.

  • A typo'd module name (module : cclock : true : 1) no longer errors --

    it silently resolves to the no-op fallback, same as any other name without a matching script.

Auto-seeded ~/.config/sxbar/scripts/

make install only ever populated $(PREFIX)/share/sxbar/scripts/ (the system-wide reference copies) โ€” nothing created or filled in ~/.config/sxbar/scripts/, the copy resolve_script() actually prefers and the one users are told to edit. On a genuinely fresh install that directory didn't exist at all, so every built-in module silently fell back to the reference copy (or, if PREFIX wasn't /usr/local, to a no-op) until the user found the docs and copied the scripts over by hand.

Changes

  • Added seed_user_scripts() (src/parser.c), called once at the top of

    parse_config(). It walks /usr/local/share/sxbar/scripts/*.sh (the same hardcoded reference path resolve_script() already falls back to) and, for each script not already present under ~/.config/sxbar/scripts/, creates that directory if needed and copies the file in with mode 0755.

  • Never overwrites a file that's already there, so hand-edited scripts

    are untouched โ€” it only fills in gaps, which also means a script added for a new built-in module in a later sxbar version appears automatically on next launch without clobbering everything else.

  • Runs as whatever user launches sxbar (never the installer, which is

    typically sudo make install), so the seeded files end up correctly user-owned instead of root-owned.

Result

  • A fresh install works out of the box: sudo make install for the

    binary and reference scripts, then just running sxbar populates ~/.config/sxbar/scripts/ with editable copies on first launch.

  • resolve_script()'s existing preference order (user copy โ†’ installed

    reference โ†’ no-op) is unchanged; this only affects how the user copy gets there in the first place.

Disk footprint

  • sxbar is lightweight: the compiled binary is about 36 KB without

    popup_image/media support, or roughly 220 KB with the vendored stb_image.h decoder compiled in -- still just the one binary, no extra shared library installed alongside it.

Live button labels (popup_live_item)

popup_item buttons have always had a fixed label, set once when the config (or a module script's menu output) is parsed. Showing a button's own current state (e.g. a toggle whose label reads "Lid-suspend: OFF") meant pairing a separate popup_info text row with a popup_item button next to it, since only popup_info/popup_image rows re-run their command and refresh on every popup open. popup_live_item collapses that into one row: same click behaviour as popup_item, but the label is a shell command re-run fresh every time the popup opens, same live convention as popup_info.

Changes

  • Added apply_popup_live_item() (src/parser.c), parsed the same way as

    apply_popup_item() -- two quoted fields -- except the first field is stored as label_command (re-run on every popup_open(), see popup_info) instead of a static label. The click field still becomes command, unchanged from popup_item.

  • No changes needed in sxbar.c: popup_open()'s per-row refresh already

    keyed off label_command being set rather than the row's type, so a POPUP_ROW_BUTTON with a label_command was already handled correctly once the parser could produce one.

  • Wired into both directive paths, same as every other popup row: sxbarc

    (popup_live_item : module_name : "label command" : "command") and a module script's own menu subcommand (popup_live_item : "label command" : "command", no module-name field).

  • The first popup_item/popup_live_item/popup_info/popup_image/

    popup_buttons line for a module still clears its built-in default rows -- popup_live_item joins that shared group.

Config syntax

popup_item      : module_name : "Label" : "command"              # button row, fixed label
popup_live_item : module_name : "label command" : "command"      # button row, label re-run on every open

Example

popup_live_item : lidsuspend : "lidsuspend status | grep -q '^ON' && echo 'Lid-suspend: ON' || echo 'Lid-suspend: OFF'" : "lidsuspend status | grep -q '^ON' && lidsuspend off || lidsuspend on"

Result

  • A single button row can now show its own live status instead of needing

    a separate informational row next to it.

  • Existing popup_item rows are unaffected -- popup_live_item is a new,

    additive directive.

  • The source tree is also small, so sxbar is ideal for minimal Xorg setups.