# sxbar The simple, yet powerful, status bar for Xorg. 📖 **[Wiki](https://htmlpreview.github.io/?https://github.com/MrJensK/sxbar/blob/main/docs/wiki.html)** — full config reference with a searchable sidebar and worked examples for every directive ([source](docs/wiki.html)). #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`: ```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//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/.sh`, then the system copy `make install` places at `/usr/local/share/sxbar/scripts/.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 ` 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. ## 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`](https://github.com/nothings/stb) (`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/` (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 : "/media.sh art" popup_info : "/media.sh track" popup_buttons : "" : "/media.sh prev" : "" : "/media.sh playpause" : "" : "/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. ## 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`) 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 : ""` 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 ` (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 `