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 : trueversion_text : sxbar ver. 1.1
- Parser now supports
show_versionandversion_textin the config file. sxbarnow draws the version text only whenshow_versionis enabled.- Default initialization now enables version display and uses the
SXBAR_VERSIONstring.
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}'" : 5custom : 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_commandfield to theModulestruct. - Added
hdl_buttonevent handler that maps a click's X position to the correct module. - Registered the handler for
ButtonPressevents (the event mask was already set). - Commands are launched in the background via double-fork so sxbar never blocks.
- Added
clickdirective 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
prefixfield to theModulestruct. - Added
prefixdirective to the config parser (iconis 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 anicon without touching their hardcoded command.
- Custom modules could already embed an icon directly in their command, but
prefixkeeps 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_commandandprefix_cachedfields to theModulestruct. - Added
prefix_cmddirective to the config parser (icon_cmdis 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 itselfrefreshes (same
refresh_interval), passing the module's freshly fetched output as$1, shell-quoted to avoid injection.module_text()prefers the liveprefix_cachedoutput over the staticprefixwhen a module has aprefix_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_widthfield to theModulestruct. - Added
widthdirective to the config parser. - Added
module_slot_width(), used in bothdraw_bar_into()andhdl_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
widthset no longer shift their neighbours around whentheir 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
Bararray (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) permonitor when
secondary_baris on. Each bar computes its own geometry and_NET_WM_STRUT_PARTIALindependently, so both reserve their own screen edge correctly.draw_bar_into(),hdl_button()now take a bar index and filter themodule list by
on_secondary; the workspace switcher and version text are skipped entirely for secondary bars.- Added
on_secondaryfield toModuleand thebarconfig directive. - Renamed
find_window_monitor/redraw_monitortofind_bar/redraw_bar(they now index into
bars, notmonitors, 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_bardefaults tooff 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
alignfield toModule(ALIGN_LEFT/ALIGN_CENTER/ALIGN_RIGHTin
src/defs.h;ALIGN_RIGHTis0so unset modules keep today's behaviour with no config changes needed). - Added
alignconfig directive. draw_bar_into()'s single module loop became three: left continues onfrom wherever the workspace switcher ended, center is centered across the full bar width, right is anchored before
version_textexactly 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
alignlines means every modulestays 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, andPopupItemfor button-menu rows. - Added
popup_type(POPUP_BUTTONS/POPUP_SLIDER),popup_trigger(
POPUP_TRIGGER_HOVER/POPUP_TRIGGER_CLICK), apopup_itemsarray andpopup_set_commandtoModule. - Added
PointerMotionMask/LeaveWindowMaskto the bar windows and newevent handlers --
hdl_motion,hdl_crossing,hdl_button_release-- registered alongside the existinghdl_button/hdl_expose. - Hovering a
hover-triggered module opens its popup; moving the pointeroff 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), spawningpopup_set_commandwith the new value (as$1, e.g."45%") only when the value actually changes. - Three new built-in modules, all opt-in (
enabled : falseby default,except
usermenu):brightness-- readsbrightnessctl -m; hover slider drags callbrightnessctl set.bluetooth-- shows adapter power state viabluetoothctl show;click menu has Turn on/off, Search for devices, and Pair last found device (
bluetoothctl pair/trust/connecton the most recently seen device -- no interactive device list).usermenu-- shows the current username (whoami); click menu hasSleep (
systemctl suspend), Log out, and Shut down (systemctl poweroff). The default logout command ispkill sxwm, since minimal WMs without a session manager have no external way to trigger their own quit keybind -- override viapopup_iteminsxbarcif 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/bluetoothctlare only needed at runtime by the modulesthat shell out to them, same as
wpctl/playerctlfor 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
volumenow opens the same hover slider asbrightness-- dragging itspawns
wpctl set-volume @DEFAULT_AUDIO_SINK@ NN%.- Added
PopupItem.label_command(src/defs.h): when set, a buttons-popuprow'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 tolabel_command. It shares the same "first line clears the built-in defaults, later lines append" rule aspopup_item, via aclear_builtin_popup_items()helper both directives now go through. - New built-in
networkmodule: bar text is the default route's interfacename (or "Offline"); its click popup has two informational rows -- first interface with a
/sys/class/net/<if>/wirelessdirectory for WiFi, first non-virtualARPHRD_ETHERinterface 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. cpunow opens abuttons+hoverpopup 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/statsamples paired up by position via an awk array, since/bin/shmay bedashand can't do process substitution) -- demonstrating thatpopup_type/popup_triggerare independent axes: any combination ofbuttons/sliderwithhover/clickis 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, orPOPUP_ROW_SLIDER-- instead of the whole popup having oneModule.popup_type.popup_typeonModulenow only means "this module has a popup at all";POPUP_SLIDERas a per-module type is gone.popup_inforows are now genuinely inert: clicking one does nothing atall (previously it closed the popup, which read as a fake button).
popup_item/popup_info/popup_setcan all target the same module and coexist in one popup --popup_setmanages a single dedicated slider row per module (tracked viaModule.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,usermenuandnetworkswitched fromclicktohovertriggers, 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 toclickper-module withpopup : module_name : click : buttonsif you'd rather.demo_menu(the try-it-yourself example) now combines all three rowkinds in one popup instead of being split across
demo_menu(buttons) and a separatedemo_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)andscript_cmd(name, extra_arg)(src/sxbar.c).resolve_scriptchecks, in order: the user's own edited copy at~/.config/sxbar/scripts/<name>.sh, then the system copymake installplaces at/usr/local/share/sxbar/scripts/<name>.sh(same fallback-path convention as the config-file lookup inparser.c), or a harmless shell no-op (:) if neither exists -- e.g. a freshly built, not-yet-installed checkout.script_cmdappends 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 orslider
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,coresnetwork.sh--status(bar text, default),wifi,ethernetbattery.sh--capacity(bar text, default),status,toggle-powersavevolume.sh/brightness.sh--get(bar text/slider start,default),
set VALUEbluetooth.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, timeremaining, health) plus a "Toggle power saver" button (
power-profiles-daemon), both opt-in extra tools beyond the/sys/class/power_supplyread the bar text itself uses
Result
scripts/now has exactly one file per built-in module (plusbattery_icon.sh/volume_icon.shforprefix_cmdicons anddemo_popup.shfor the try-it-yourself popup) -- copy any of them to~/.config/sxbar/scripts/and edit freely, same as the existingprefix_cmdconvention, 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
menusubcommand (e.g.usermenu.sh menu). Run once at startup, it prints that module's popup definition using the exact samepopup/popup_item/popup_info/popup_setdirectives 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 inparser.h): runs<script_path> menuand feeds each line through the same directive parsing sxbarc's ownpopup/popup_item/popup_info/popup_setlines use. - Refactored that parsing itself: the four directives' bodies (previously
duplicated between
parse_config()'s sxbarc-line handling andinit_modules()'s compile-time-default helpers) are now the singleapply_popup()/apply_popup_item()/apply_popup_info()/apply_popup_set()functions inparser.c, shared by both callers. init_modules()(src/sxbar.c) shrank from one hardcoded structliteral plus manual
add_popup_item/add_popup_info_item/add_popup_slider_itemcalls per module, to a singleadd_builtin_module(name, enabled, refresh_interval)helper that resolves the script and callsload_popup_from_script. The oldadd_popup_*/script_cmdhelpers 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_setdirectives areunchanged and still work exactly as before: the first
popup_item/popup_infoline 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 toload a menu from, so this is a no-op for those -- give it a popup via sxbarc's directives as before.
Popup images (album art), segmented button rows, and a media module
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_IMPLEMENTATIONinsrc/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-devpackage needed to build. The only new link flag is-lm(Makefile), for thepow/ldexpcalls stb_image's decoders use. - New
POPUP_ROW_IMAGErow type (src/defs.h).PopupItemgainedimage_command(a shell command, re-run every popup open just likelabel_command-- its stdout is a path to a local image file, or empty for "no image this time"), plusimage(anXImage *, opaque asvoid *indefs.hsoparser.conly ever touchesimage_command, a plain string) andimage_w/image_h(its current on-screen size). - New
popup_image : module_name : "command"config directive(
src/parser.c), parsed the same way aspopup_info-- including amenu-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 aPOPUP_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 anXImage(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 finalXCopyAreablits everything to the window, same as every other row. The previousXImageis freed (XDestroyImage()) before loading a new one on each open, and on program exit viacleanup_modules().- New
POPUP_ROW_BUTTONSrow type (src/defs.h) for a row split into Nequal-width button segments side by side, instead of N stacked full-width
POPUP_ROW_BUTTONrows.PopupItemgainedbuttons/button_count-- an array ofPopupButton { label, command }pairs; labels are static (no per-segmentlabel_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:). Samemenu-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 acrossbutton_countsegments (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 existingpopup.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 apopup_inforow.art-- resolvesplayerctl metadata mpris:artUrl: afile://URLis used directly, an
http(s)://one is downloaded once and cached under$XDG_CACHE_HOME/sxbar-media-art/<hash-of-url>(needscurl; silently produces no output ifcurlisn't installed, or the URL isn't reachable) -- for apopup_imagerow.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 viafc-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).
- bar text (default): a play/pause glyph plus "Artist - Title" via
default_sxbarc: addedmediato the built-in module list, apopup_image/popup_info/popup_buttonsoverride 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 : 2gets album art, track info and transportcontrols 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 viapopup_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) gainedmax_width(0 = no cap, same conventionas
min_width) andscroll_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_pixelsconfig directive(
src/parser.c), parsed identically to the existingwidthdirective. module_slot_width()(src/sxbar.c) now caps a module's reserved slotat
max_widthwhen 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 inmodule_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 plainXftDrawStringUtf8()call at all three module-drawing sites (left/center/right groups) indraw_bar_into(). Text that fits draws exactly as before; text that overflows is clipped tomax_widthviaXftDrawSetClipRectangles()and drawn twice back to back ("text "repeated) offset byscroll_offset, so the wrap-around reads as one continuous ticker instead of jumping at the seam. - New
advance_marquees()(src/sxbar.c): each call, advancesscroll_offsetby a fixed step for every enabled module currently overflowing itsmax_width, and reports whether any did. run()'s main loop now callsadvance_marquees()every ~100ms tick andredraws immediately if it reports anything scrolling, independent of the normal once-a-second
update_modules()/redraw cadence -- a config that never setsmax_widthsees 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.
Popup text rows now cap to the popup's own width too
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) gainedscroll_offset, the same idea as aModule'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 orcpu'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 throughdraw_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 ofadvance_marquees(): advancesscroll_offsetfor 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 fromrun()'s existing ~100ms tick. - Each row's
scroll_offsetresets to 0 inpopup_open(), so a marqueealways 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 thetransport-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, ...) isunaffected -- 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 andworkspace_icons/workspace_icon_count/workspace_icon_maxfields onConfig(src/defs.h). - New
workspace_icon : name : "icon text"config directive(
src/parser.c), parsed the same quote-delimited way asprefix; repeatable, growable array, same realloc-doubling pattern asgrow_popup_items(). - New
workspace_display_name(name)(src/sxbar.c): looksname(theWM-reported desktop name) up against the configured list and returns its replacement text if there's a match, else
nameunchanged. - The three places that format a workspace's label into
" %s "--draw_bar_into()'s width-measuring pass, its drawing pass, andworkspace_end_x()(which mirrors the same layout math for click hit-testing) -- all go through this lookup now instead of usingnamedirectly. - 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 measuringinner(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'sXftDrawStringUtf8()calls indraw_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 anyglyph from your Nerd Font, purely cosmetically -- switching still targets the same underlying desktop number, only what's drawn changes.
- Workspaces with no matching
workspace_iconline keep showing theirplain 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/
listoutput isn't bar text -- it's an internal wire format, one line per current-workspace window:"Title" : "0xID" : "command", sourced fromwmctrl -lfiltered to whichever desktopwmctrl -dmarks current.focus IDrunswmctrl -i -a ID.menuis 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 andtaskbar_entries/taskbar_entry_countfields onModule(src/defs.h).taskbaris 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 fromupdate_modules()instead of the normal run-command-into-cached_output path when
m->nameis"taskbar": runs the script withlistappended and parses each line via a small dedicatedparse_three_quoted()(this is an internal wire format, not a user-facing sxbarc directive, so it doesn't reuseparser.c's quote-parsing). Deliberately leavesm->cached_outputNULL-- every generic per-module code path (draw_bar_into()'s layout/rendering loops,module_at_x(),advance_marquees()) already skips modules with nocached_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 thetaskbar module, computes the gap between wherever the left-aligned and right-aligned module groups end (reusing
total_left/total_rightalready computed for those groups), and splits that gap into one equal-width segment per window entry. Reads_NET_ACTIVE_WINDOWfresh each draw and highlights the matching entry's segment the same way the active workspace pill is highlighted. Ignores the module's ownalign-- a fill module has no single anchor side. - New
taskbar_entry_at_x()(src/sxbar.c), mirroring that block'sbounds/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 intohdl_button(): whenmodule_at_x()finds nothing (which it never will for taskbar, since itscached_outputisNULL), a left click falls through to this instead, spawning the matched entry's command.
Result
module : taskbar : true : 1plusbar : taskbar : secondary(withsecondary_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 WMthat honours
_NET_ACTIVE_WINDOWrequests -- 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; itsmenusubcommand 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 ownpopup_itemlines -- no sxbarc editing needed, same as editingusermenu.sh's Sleep/Log out/Shut down rows. - New
scripts/demo_menu.sh: a runnable reference combining one of everypopup 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 asmedia.sh art),popup_item,popup_set(slider), andpopup_buttons(three segments). The button/slider rows fire a desktop notification viascripts/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 : 3600is a working start menu.module : demo_menu : true : 999is a hands-on reference for everypopup 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 fromsxbar.ctoparser.c(static, same behaviour) so themoduledirective's parsing can call it directly.module : name : enabled : interval(src/parser.c) now creates themodule on the spot if
nameisn't already registered, instead of erroringunknown builtin module: resolves the script viaresolve_script(), then self-declares its popup viaload_popup_from_script()-- the exact sequenceadd_builtin_module()used to run at startup for the fixed list, now run lazily for whichever names sxbarc actually mentions.- The
customdirective is gone. A one-off shell pipeline that used to bea
customline now needs an actual script file, e.g.~/.config/sxbar/scripts/temp.shcontainingsensors | grep 'Package' | awk '{print $4}', enabled withmodule : temp : true : 5like anything else. An old config'scustom :lines are now an unrecognized directive (a harmless per-line warning, config parsing continues). add_builtin_module()and the elevenadd_builtin_module("clock", ...)etc. calls are gone from
init_modules()(src/sxbar.c), which now just allocates the (still growable)config.modulesarray and returns -- every module, including the ones sxbar ships a script for, is created entirely from sxbarc'smodule :lines via the mechanism above.default_sxbarcis unchanged in what it enables by default, since it already had amodule :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, addmodule : <name> : true : <interval>to sxbarc. Nocustomsyntax 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 ofparse_config(). It walks/usr/local/share/sxbar/scripts/*.sh(the same hardcoded reference pathresolve_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 mode0755. - 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
sxbarversion appears automatically on next launch without clobbering everything else. - Runs as whatever user launches
sxbar(never the installer, which istypically
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 installfor thebinary and reference scripts, then just running
sxbarpopulates~/.config/sxbar/scripts/with editable copies on first launch. resolve_script()'s existing preference order (user copy โ installedreference โ no-op) is unchanged; this only affects how the user copy gets there in the first place.
Disk footprint
sxbaris lightweight: the compiled binary is about 36 KB withoutpopup_image/mediasupport, or roughly 220 KB with the vendoredstb_image.hdecoder 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 asapply_popup_item()-- two quoted fields -- except the first field is stored aslabel_command(re-run on everypopup_open(), seepopup_info) instead of a staticlabel. The click field still becomescommand, unchanged frompopup_item. - No changes needed in
sxbar.c:popup_open()'s per-row refresh alreadykeyed off
label_commandbeing set rather than the row's type, so aPOPUP_ROW_BUTTONwith alabel_commandwas 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 ownmenusubcommand (popup_live_item : "label command" : "command", no module-name field). - The first
popup_item/popup_live_item/popup_info/popup_image/popup_buttonsline for a module still clears its built-in default rows --popup_live_itemjoins 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_itemrows are unaffected --popup_live_itemis a new,additive directive.
- The source tree is also small, so
sxbaris ideal for minimal Xorg setups.