foxygit / sxbar Log in
commit e0c13f69e2e6ff3fad32d03524ed35b16fa5ff79
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Tue Jul 28 19:56:58 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Tue Jul 28 19:56:58 2026 +0200

    lots and lots of changes, including a new battery icon and volume icon script, as well as updates to the Makefile, README, and source files.
---
 Makefile                |   5 +
 README.md               | 230 ++++++++++++++++++++++++++++
 default_sxbarc          |  74 ++++++++-
 scripts/battery_icon.sh |  20 +++
 scripts/volume_icon.sh  |  17 +++
 src/defs.h              |  23 +++
 src/parser.c            |  91 ++++++++++-
 src/sxbar.c             | 394 ++++++++++++++++++++++++++++++++----------------
 8 files changed, 716 insertions(+), 138 deletions(-)

diff --git a/Makefile b/Makefile
index a44d8fc..9957087 100644
--- a/Makefile
+++ b/Makefile
@@ -40,6 +40,9 @@ install: all
 	@echo "Copying default configuration to $(DESTDIR)$(PREFIX)/share/sxbarc..."
 	@mkdir -p "$(DESTDIR)$(PREFIX)/share"
 	@install -m 644 default_sxbarc "$(DESTDIR)$(PREFIX)/share/sxbarc"
+	@echo "Installing example prefix_cmd scripts to $(DESTDIR)$(PREFIX)/share/sxbar/scripts..."
+	@mkdir -p "$(DESTDIR)$(PREFIX)/share/sxbar/scripts"
+	@install -m 755 scripts/*.sh "$(DESTDIR)$(PREFIX)/share/sxbar/scripts/"
 	@echo "Installation complete."

 uninstall:
@@ -47,6 +50,8 @@ uninstall:
 	@rm -f "$(DESTDIR)$(PREFIX)/bin/$(BIN)"
 	@echo "Uninstalling man page from $(DESTDIR)$(MAN_DIR)..."
 	@rm -f $(DESTDIR)$(MAN_DIR)/$(MAN)
+	@echo "Uninstalling example scripts from $(DESTDIR)$(PREFIX)/share/sxbar..."
+	@rm -rf "$(DESTDIR)$(PREFIX)/share/sxbar"
 	@echo "Uninstallation complete."

 .PHONY: all clean install uninstall
diff --git a/README.md b/README.md
index 90fd51f..467caac 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,236 @@ prefix : cpu     : "  "
 - 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/`, so copy and edit from 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.
+
 ## Disk footprint

 - `sxbar` is very lightweight: the compiled binary is about 36 KB.
diff --git a/default_sxbarc b/default_sxbarc
index aa90609..2cd2464 100644
--- a/default_sxbarc
+++ b/default_sxbarc
@@ -9,11 +9,19 @@ version_text         : sxbar ver. 1.1
 # Appearance
 height              : 20
 bottom_bar          : false
+# secondary_bar: adds a second, modules-only bar on the edge opposite
+# `bottom_bar` (no workspace switcher, no version text). Tag which modules
+# go on it further down with `bar : module_name : secondary` -- everything
+# else stays on the primary bar. See that section below for details.
+secondary_bar       : false
 vertical_padding    : 0
 horizontal_padding  : 0
 text_padding        : 5
 border              : false
 border_width        : 1
+# Note: unlike the per-module `colour` directive and most other keys
+# below, these three don't support a trailing "# comment" on the same
+# line -- put comments on their own line instead.
 background_colour   : #000000
 foreground_colour   : #7abccd
 border_colour       : #005577
@@ -49,10 +57,68 @@ module : cpu     : false : 3
 # (icon : ... is accepted as an alias for prefix)
 #
 # Examples:
-# prefix : battery : "  "
-# prefix : volume  : "  "
-# prefix : cpu     : "  "
-# prefix : clock   : "  "
+# prefix : clock   : " "
+# prefix : date    : " "
+# prefix : battery : " "
+# prefix : volume  : " "
+# prefix : cpu     : " "
+
+# Prefix command -- like `prefix`, but the icon comes from an external
+# script instead of fixed text, so it can change with module state (e.g.
+# battery charging vs discharging, volume muted vs not). The script's own
+# stdout (trailing newline stripped) becomes the prefix; it is re-run on
+# the module's normal refresh_interval, and it must include its own
+# trailing space/separator if you want one before the value.
+# The module's current output (e.g. "83%") is passed in as $1, since the
+# built-in module commands don't expose state like charging/muted beyond
+# that string -- scripts that need it re-check the system themselves.
+# prefix_cmd : module_name : "command or script path"
+# (icon_cmd : ... is accepted as an alias for prefix_cmd)
+#
+# Examples:
+# prefix_cmd : battery : "~/.config/sxbar/scripts/battery_icon.sh"
+# prefix_cmd : volume  : "~/.config/sxbar/scripts/volume_icon.sh"
+#
+# Reference copies of these two scripts ship in this repo's scripts/ dir
+# and are installed to $(PREFIX)/share/sxbar/scripts/ -- copy them to
+# ~/.config/sxbar/scripts/ (the path above) and edit from there.
+
+# Width -- reserves a minimum pixel width for a module's slot, so the rest
+# of the bar doesn't shift when its text width changes (e.g. cpu going
+# from one digit to two, or three, wide). The module's own text is never
+# truncated; if it's wider than this it just uses its natural width.
+# width : module_name : min_pixels
+#
+# Examples (picking a value wide enough for the largest expected reading,
+# e.g. the width of "100%" plus its icon in your font/size):
+# width : battery : 48
+# width : volume  : 48
+# width : cpu     : 48
+
+# Bar assignment -- which bar a module is drawn on when secondary_bar is
+# enabled above. Defaults to primary; only needs setting for modules you
+# want to move to the secondary bar. The secondary bar has no workspace
+# switcher and never shows version_text, regardless of show_version --
+# it only ever draws the modules tagged secondary here.
+# bar : module_name : primary|secondary
+#
+# Example (secondary_bar : true, bottom_bar : false puts battery/volume/cpu
+# on their own bar at the bottom, clock/date/workspaces stay on top):
+# bar : battery : secondary
+# bar : volume  : secondary
+# bar : cpu     : secondary
+
+# Alignment -- which side of the bar a module is anchored to. Defaults to
+# right, so existing configs (which never set this) are unaffected. Each
+# group (left/center/right) is laid out independently -- left continues on
+# from the workspace switcher, center is centered in the bar, right is
+# anchored before version_text, same as today's default cluster.
+# align : module_name : left|center|right
+#
+# Example (workspaces+clock on the left, date centered, the rest on the
+# right where they already are by default):
+# align : clock : left
+# align : date  : center

 # Per-module text colour (overrides global foreground_colour for that module)
 # colour : module_name : #rrggbb
diff --git a/scripts/battery_icon.sh b/scripts/battery_icon.sh
new file mode 100755
index 0000000..2dc8299
--- /dev/null
+++ b/scripts/battery_icon.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# prefix_cmd for the battery module -- picks a Nerd Font glyph based on
+# charge level and charging status.
+# $1 is the battery module's own output (e.g. "88%"); charging status isn't
+# in that string, so we re-check /sys ourselves.
+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 ' ''
+    exit 0
+fi
+
+if   [ "$pct" -ge 90 ]; then printf '%s ' ''
+elif [ "$pct" -ge 50 ]; then printf '%s ' ''
+elif [ "$pct" -ge 20 ]; then printf '%s ' ''
+elif [ "$pct" -ge 10 ]; then printf '%s ' ''
+else printf '%s ' ''
+fi
diff --git a/scripts/volume_icon.sh b/scripts/volume_icon.sh
new file mode 100755
index 0000000..7177b74
--- /dev/null
+++ b/scripts/volume_icon.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# prefix_cmd for the volume module -- picks a Nerd Font glyph based on level
+# and mute state.
+# $1 is the volume module's own output (e.g. "40%"); the built-in command
+# strips the [MUTED] marker to keep the number clean, so we re-check wpctl
+# ourselves here.
+pct=$(printf '%s' "$1" | tr -dc '0-9')
+pct=${pct:-0}
+muted=$(LC_ALL=C wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null | grep -c MUTED)
+
+if [ "$muted" -gt 0 ]; then
+    printf '%s ' ''
+elif [ "$pct" -ge 50 ]; then
+    printf '%s ' ''
+else
+    printf '%s ' ''
+fi
diff --git a/src/defs.h b/src/defs.h
index 41be285..9f44fa1 100644
--- a/src/defs.h
+++ b/src/defs.h
@@ -10,6 +10,12 @@

 #define MAX_MONITORS 32

+/* module horizontal alignment groups -- RIGHT is 0 so existing configs
+ * (which never set align) keep today's right-anchored-cluster behaviour */
+#define ALIGN_RIGHT  0
+#define ALIGN_LEFT   1
+#define ALIGN_CENTER 2
+
 typedef struct Module {
 	char *name;
 	char *command;
@@ -18,6 +24,11 @@ typedef struct Module {
 	char *scroll_down_command;
 	char *colour;
 	char *prefix;
+	char *prefix_command;
+	char *prefix_cached;
+	int min_width;
+	int on_secondary;
+	int align;
 	XftColor xft_colour;
 	int has_colour;
 	int enabled;
@@ -43,6 +54,18 @@ typedef struct Config {
 	Module *modules;
 	int module_count;
 	int max_modules;
+	int secondary_bar;
 } Config;

+/* one on-screen bar window: either the primary bar (workspaces + all
+ * primary-tagged modules + version) or the secondary bar (modules tagged
+ * `bar : module_name : secondary` only, on the opposite edge) */
+typedef struct Bar {
+	int monitor;
+	int is_secondary;
+	Window win;
+	Pixmap buffer;
+	XftDraw *xft_draw;
+} Bar;
+
 typedef void (*EventHandler)(XEvent *);
diff --git a/src/parser.c b/src/parser.c
index 9f302ab..14fe50e 100644
--- a/src/parser.c
+++ b/src/parser.c
@@ -25,7 +25,11 @@ static char *strip(char *s)

 static char *strip_comment(char *s)
 {
-	char *c = strchr(s, '#');
+	/* a '#' as the very first character is a value (e.g. a hex colour like
+	 * #50fa7b), not a comment marker -- only a later '#' starts a trailing
+	 * comment (e.g. "#50fa7b  # my favourite green") */
+	char *scan = (*s == '#') ? s + 1 : s;
+	char *c = strchr(scan, '#');
 	if (c)
 		*c = '\0';
 	return strip(s);
@@ -175,6 +179,10 @@ int parse_config(Config *cfg)
 			strip_comment(rest);
 			free(cfg->version_text);
 			cfg->version_text = strdup(rest);
+		} else if (!strcmp(key, "secondary_bar")) {
+			/* enable a second bar on the opposite edge from `bottom_bar`,
+			 * showing only modules tagged `bar : module_name : secondary` */
+			cfg->secondary_bar = parse_bool(rest);
 		} else if (!strcmp(key, "module")) {
 			/* module : name : enabled : interval */
 			char *p1 = strchr(rest, ':');
@@ -277,8 +285,80 @@ int parse_config(Config *cfg)
 			}
 			free(m->colour);
 			m->colour = strdup(val);
-		} else if (!strcmp(key, "click") || !strcmp(key, "scroll_up") || !strcmp(key, "scroll_down")) {
-			/* click/scroll_up/scroll_down : module_name : "command" */
+		} else if (!strcmp(key, "width")) {
+			/* width : module_name : min_pixels -- reserves at least this much
+			 * horizontal space for the module so its neighbours don't shift
+			 * when its text width changes (e.g. cpu going from 9% to 16%) */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: width missing name and value\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name = strip(rest);
+			char *val  = strip(p1 + 1);
+			strip_comment(val);
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: width: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			m->min_width = atoi(val);
+		} else if (!strcmp(key, "bar")) {
+			/* bar : module_name : primary|secondary -- which bar the module
+			 * is drawn on when secondary_bar is enabled (default: primary) */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: bar missing name and value\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name = strip(rest);
+			char *val  = strip(p1 + 1);
+			strip_comment(val);
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: bar: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			if (!strcmp(val, "secondary")) {
+				m->on_secondary = 1;
+			} else if (!strcmp(val, "primary")) {
+				m->on_secondary = 0;
+			} else {
+				fprintf(stderr, "sxbarc:%d: bar: value must be 'primary' or 'secondary', got '%s'\n",
+				        lineno, val);
+			}
+		} else if (!strcmp(key, "align")) {
+			/* align : module_name : left|center|right -- which side of the
+			 * bar the module is anchored to (default: right) */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: align missing name and value\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name = strip(rest);
+			char *val  = strip(p1 + 1);
+			strip_comment(val);
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: align: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			if (!strcmp(val, "left")) {
+				m->align = ALIGN_LEFT;
+			} else if (!strcmp(val, "center") || !strcmp(val, "centre")) {
+				m->align = ALIGN_CENTER;
+			} else if (!strcmp(val, "right")) {
+				m->align = ALIGN_RIGHT;
+			} else {
+				fprintf(stderr, "sxbarc:%d: align: value must be 'left', 'center' or 'right', got '%s'\n",
+				        lineno, val);
+			}
+		} else if (!strcmp(key, "click") || !strcmp(key, "scroll_up") || !strcmp(key, "scroll_down") ||
+		           !strcmp(key, "prefix_cmd") || !strcmp(key, "icon_cmd")) {
+			/* click/scroll_up/scroll_down/prefix_cmd/icon_cmd : module_name : "command" */
 			char *p1 = strchr(rest, ':');
 			if (!p1) {
 				fprintf(stderr, "sxbarc:%d: %s missing name and command\n", lineno, key);
@@ -312,9 +392,12 @@ int parse_config(Config *cfg)
 			} else if (!strcmp(key, "scroll_up")) {
 				free(m->scroll_up_command);
 				m->scroll_up_command = expand_home(cmd_start);
-			} else {
+			} else if (!strcmp(key, "scroll_down")) {
 				free(m->scroll_down_command);
 				m->scroll_down_command = expand_home(cmd_start);
+			} else {
+				free(m->prefix_command);
+				m->prefix_command = expand_home(cmd_start);
 			}
 		} else if (!strcmp(key, "prefix") || !strcmp(key, "icon")) {
 			/* prefix/icon : module_name : "text" -- prepended to the module's output, e.g. a Nerd Font glyph */
diff --git a/src/sxbar.c b/src/sxbar.c
index f0cf8e7..62e21c6 100644
--- a/src/sxbar.c
+++ b/src/sxbar.c
@@ -19,9 +19,9 @@
 void cleanup_modules(void);
 void cleanup_resources(void);
 void create_bars(void);
-static void draw_bar_into(Drawable draw, int monitor_index);
-static void redraw_monitor(int monitor_index);
-int find_window_monitor(Window win);
+static void draw_bar_into(int bar_idx);
+static void redraw_bar(int bar_idx);
+int find_bar(Window win);
 int get_current_workspace(void);
 char **get_workspace_name(int *count);
 void hdl_button(XEvent *xev);
@@ -38,16 +38,15 @@ void update_modules(void);

 EventHandler evtable[LASTEvent];
 XftFont  *font;
-XftDraw **xft_draws;
 XftColor  xft_fg;
 XftColor  xft_bg;
 Display *dpy;
 Window root;
-Window *wins = NULL;
 XineramaScreenInfo *monitors = NULL;
 GC gc;
 Config config;
-Pixmap *buffers = NULL;
+Bar *bars = NULL;
+int nbars = 0;
 int nmonitors = 0;
 int scr;

@@ -93,16 +92,26 @@ static int text_width(const char *str)
 	return ext.xOff;
 }

-/* module's cached output with its configured prefix (e.g. a Nerd Font glyph) prepended */
+/* module's cached output with its configured prefix (static text, or the
+ * live output of a prefix_cmd script) prepended */
 static const char *module_text(Module *m, char *buf, size_t bufsz)
 {
-	if (m->prefix && *m->prefix) {
-		snprintf(buf, bufsz, "%s%s", m->prefix, m->cached_output);
+	const char *pre = m->prefix_command ? m->prefix_cached : m->prefix;
+	if (pre && *pre) {
+		snprintf(buf, bufsz, "%s%s", pre, m->cached_output);
 		return buf;
 	}
 	return m->cached_output;
 }

+/* space a module reserves in the layout: its text width, or its configured
+ * minimum, whichever is larger -- keeps everything else on the bar from
+ * shifting when the text width changes (e.g. cpu going from one digit to two) */
+static int module_slot_width(Module *m, int text_w)
+{
+	return text_w > m->min_width ? text_w : m->min_width;
+}
+
 static void pixel_to_xftcolor(unsigned long pixel, XftColor *out)
 {
 	XColor xc = {0};
@@ -121,10 +130,14 @@ static void resolve_module_colours(void)
 	Colormap cmap = DefaultColormap(dpy, scr);
 	for (int i = 0; i < config.module_count; i++) {
 		if (config.modules[i].colour) {
-			XftColorAllocName(dpy, vis, cmap,
+			if (XftColorAllocName(dpy, vis, cmap,
 			                  config.modules[i].colour,
-			                  &config.modules[i].xft_colour);
-			config.modules[i].has_colour = 1;
+			                  &config.modules[i].xft_colour)) {
+				config.modules[i].has_colour = 1;
+			} else {
+				fprintf(stderr, "sxbar: cannot parse/color %s for module %s\n",
+				        config.modules[i].colour, config.modules[i].name);
+			}
 		}
 	}
 }
@@ -141,6 +154,8 @@ void cleanup_modules(void)
 		free(config.modules[i].scroll_down_command);
 		free(config.modules[i].colour);
 		free(config.modules[i].prefix);
+		free(config.modules[i].prefix_command);
+		free(config.modules[i].prefix_cached);
 		if (config.modules[i].has_colour)
 			XftColorFree(dpy, vis, cmap, &config.modules[i].xft_colour);
 		free(config.modules[i].cached_output);
@@ -150,26 +165,17 @@ void cleanup_modules(void)

 void cleanup_resources(void)
 {
-	if (buffers) {
-		for (int i = 0; i < nmonitors; i++) {
-			XFreePixmap(dpy, buffers[i]);
-		}
-		free(buffers);
-	}
-	if (wins) {
-		for (int i = 0; i < nmonitors; i++) {
-			XDestroyWindow(dpy, wins[i]);
+	if (bars) {
+		for (int i = 0; i < nbars; i++) {
+			XftDrawDestroy(bars[i].xft_draw);
+			XFreePixmap(dpy, bars[i].buffer);
+			XDestroyWindow(dpy, bars[i].win);
 		}
-		free(wins);
+		free(bars);
 	}
 	if (monitors) {
 		XFree(monitors);
 	}
-	if (xft_draws) {
-		for (int i = 0; i < nmonitors; i++)
-			XftDrawDestroy(xft_draws[i]);
-		free(xft_draws);
-	}
 	if (font)
 		XftFontClose(dpy, font);
 	if (gc)
@@ -200,55 +206,71 @@ void create_bars(void)
 		monitors[0].height = DisplayHeight(dpy, scr);
 	}

-	wins = malloc(nmonitors * sizeof *wins);
-	buffers = malloc(nmonitors * sizeof *buffers);
+	/* one bar per monitor, or two (primary + secondary, opposite edges) if
+	 * secondary_bar is enabled */
+	int variants = config.secondary_bar ? 2 : 1;
+	nbars = nmonitors * variants;
+	bars = malloc(nbars * sizeof *bars);

+	int bidx = 0;
 	for (int i = 0; i < nmonitors; i++) {
-		int bw = config.border ? config.border_width : 0;
-		int w = monitors[i].width - 2 * config.horizontal_padding;
-		int h = config.height;
-		int x = monitors[i].x_org + config.horizontal_padding;
-		int y = config.bottom_bar
-		            ? monitors[i].y_org + monitors[i].height - h - config.vertical_padding - bw
-		            : monitors[i].y_org + config.vertical_padding;
-
-		XSetWindowAttributes wa = {.background_pixel = config.background_colour,
-		                           .border_pixel = config.border_colour,
-		                           .event_mask = ExposureMask | ButtonPressMask};
-
-		wins[i] = XCreateWindow(dpy, root, x, y, w, h, bw, CopyFromParent, InputOutput,
-		                        DefaultVisual(dpy, scr),
-		                        CWBackPixel | CWBorderPixel | CWEventMask, &wa);
-
-		XStoreName(dpy, wins[i], "sxbar");
-		XClassHint ch = {"sxbar", "sxbar"};
-		XSetClassHint(dpy, wins[i], &ch);
-
-		Atom A_WM_TYPE = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE", False);
-		Atom A_WM_TYPE_DOCK = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE_DOCK", False);
-		XChangeProperty(dpy, wins[i], A_WM_TYPE, XA_ATOM, 32, PropModeReplace,
-		                (unsigned char *)&A_WM_TYPE_DOCK, 1);
-
-		Atom A_STRUT = XInternAtom(dpy, "_NET_WM_STRUT_PARTIAL", False);
-		long strut[12] = {0};
-		if (config.bottom_bar) {
-			strut[3] = h + bw;
-			strut[10] = x;
-			strut[11] = x + w + 2 * bw - 1;
-		}
-		else {
-			strut[2] = y + h + bw;
-			strut[8] = x;
-			strut[9] = x + w + 2 * bw - 1;
-		}
-		XChangeProperty(dpy, wins[i], A_STRUT, XA_CARDINAL, 32, PropModeReplace,
-		                (unsigned char *)strut, 12);
+		for (int v = 0; v < variants; v++) {
+			int is_secondary = v;
+			/* secondary bar sits on the edge opposite the primary bar */
+			int bottom_bar = is_secondary ? !config.bottom_bar : config.bottom_bar;
+
+			int bw = config.border ? config.border_width : 0;
+			int w = monitors[i].width - 2 * config.horizontal_padding;
+			int h = config.height;
+			int x = monitors[i].x_org + config.horizontal_padding;
+			int y = bottom_bar
+			            ? monitors[i].y_org + monitors[i].height - h - config.vertical_padding - bw
+			            : monitors[i].y_org + config.vertical_padding;
+
+			XSetWindowAttributes wa = {.background_pixel = config.background_colour,
+			                           .border_pixel = config.border_colour,
+			                           .event_mask = ExposureMask | ButtonPressMask};
+
+			Window win = XCreateWindow(dpy, root, x, y, w, h, bw, CopyFromParent, InputOutput,
+			                        DefaultVisual(dpy, scr),
+			                        CWBackPixel | CWBorderPixel | CWEventMask, &wa);
+
+			XStoreName(dpy, win, is_secondary ? "sxbar-secondary" : "sxbar");
+			XClassHint ch = {"sxbar", is_secondary ? "sxbar-secondary" : "sxbar"};
+			XSetClassHint(dpy, win, &ch);
+
+			Atom A_WM_TYPE = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE", False);
+			Atom A_WM_TYPE_DOCK = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE_DOCK", False);
+			XChangeProperty(dpy, win, A_WM_TYPE, XA_ATOM, 32, PropModeReplace,
+			                (unsigned char *)&A_WM_TYPE_DOCK, 1);
+
+			Atom A_STRUT = XInternAtom(dpy, "_NET_WM_STRUT_PARTIAL", False);
+			long strut[12] = {0};
+			if (bottom_bar) {
+				strut[3] = h + bw;
+				strut[10] = x;
+				strut[11] = x + w + 2 * bw - 1;
+			}
+			else {
+				strut[2] = y + h + bw;
+				strut[8] = x;
+				strut[9] = x + w + 2 * bw - 1;
+			}
+			XChangeProperty(dpy, win, A_STRUT, XA_CARDINAL, 32, PropModeReplace,
+			                (unsigned char *)strut, 12);
+
+			Pixmap buf = XCreatePixmap(dpy, win, w, h, DefaultDepth(dpy, scr));
+			XMapRaised(dpy, win);

-		buffers[i] = XCreatePixmap(dpy, wins[i], w, h, DefaultDepth(dpy, scr));
-		XMapRaised(dpy, wins[i]);
+			bars[bidx].monitor = i;
+			bars[bidx].is_secondary = is_secondary;
+			bars[bidx].win = win;
+			bars[bidx].buffer = buf;
+			bidx++;
+		}
 	}

-	gc   = XCreateGC(dpy, wins[0], 0, NULL);
+	gc   = XCreateGC(dpy, bars[0].win, 0, NULL);
 	font = XftFontOpenName(dpy, scr, config.font);
 	if (!font)
 		errx(1, "could not load font %s", config.font);
@@ -258,14 +280,17 @@ void create_bars(void)
 	pixel_to_xftcolor(config.foreground_colour, &xft_fg);
 	pixel_to_xftcolor(config.background_colour, &xft_bg);

-	xft_draws = malloc(nmonitors * sizeof *xft_draws);
-	for (int i = 0; i < nmonitors; i++)
-		xft_draws[i] = XftDrawCreate(dpy, buffers[i], vis, cmap);
+	for (int i = 0; i < nbars; i++)
+		bars[i].xft_draw = XftDrawCreate(dpy, bars[i].buffer, vis, cmap);
 }

-static void draw_bar_into(Drawable draw, int monitor_index)
+static void draw_bar_into(int bar_idx)
 {
-	XftDraw *d = xft_draws[monitor_index];
+	Bar *bar = &bars[bar_idx];
+	int monitor_index = bar->monitor;
+	int is_secondary = bar->is_secondary;
+	Drawable draw = bar->buffer;
+	XftDraw *d = bar->xft_draw;
 	int w = monitors[monitor_index].width - 2 * config.horizontal_padding;
 	int h = config.height;

@@ -273,14 +298,16 @@ static void draw_bar_into(Drawable draw, int monitor_index)
 	XSetForeground(dpy, gc, config.background_colour);
 	XFillRectangle(dpy, draw, gc, 0, 0, w, h);

-	int current_ws = get_current_workspace();
-	int name_count = 0;
-	char **names = get_workspace_name(&name_count);
-
 	int text_y = (h + font->ascent - font->descent) / 2;
 	const int pad = 5, ws_sp = 10, mod_sp = 20;
 	int cur_x = config.text_padding + pad;

+	/* the secondary bar only ever shows modules tagged `bar : ... : secondary` --
+	 * no workspace switcher, no version text */
+	int current_ws = is_secondary ? -1 : get_current_workspace();
+	int name_count = 0;
+	char **names = is_secondary ? NULL : get_workspace_name(&name_count);
+
 	/* workspaces */
 	if (names) {
 		int *pos = malloc(name_count * sizeof *pos);
@@ -355,30 +382,63 @@ static void draw_bar_into(Drawable draw, int monitor_index)
 		free(wd);
 	}

-	/* modules */
+	/* modules: split into left/center/right groups, each laid out and
+	 * anchored independently of the other two */
 	char mbuf[256];
-	int total_mw = 0;
+	int total_left = 0, total_center = 0, total_right = 0;
+	for (int i = 0; i < config.module_count; i++) {
+		Module *m = &config.modules[i];
+		if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary)
+			continue;
+		int tw = text_width(module_text(m, mbuf, sizeof mbuf));
+		int slot = module_slot_width(m, tw) + mod_sp;
+		if (m->align == ALIGN_LEFT) total_left += slot;
+		else if (m->align == ALIGN_CENTER) total_center += slot;
+		else total_right += slot;
+	}
+	int ver_w = (!is_secondary && config.show_version) ? text_width(config.version_text) : 0;
+
+	/* left group: continues on from wherever the workspace switcher ended */
+	int lx = cur_x;
+	for (int i = 0; i < config.module_count; i++) {
+		Module *m = &config.modules[i];
+		if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary || m->align != ALIGN_LEFT)
+			continue;
+		const char *out = module_text(m, mbuf, sizeof mbuf);
+		int tw = text_width(out);
+		XftColor *col = m->has_colour ? &m->xft_colour : &xft_fg;
+		XftDrawStringUtf8(d, col, font, lx, text_y, (const FcChar8 *)out, strlen(out));
+		lx += module_slot_width(m, tw) + mod_sp;
+	}
+
+	/* center group: centered across the full bar width */
+	int cx = (w - total_center) / 2;
 	for (int i = 0; i < config.module_count; i++) {
-		if (!config.modules[i].enabled || !config.modules[i].cached_output)
+		Module *m = &config.modules[i];
+		if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary || m->align != ALIGN_CENTER)
 			continue;
-		total_mw += text_width(module_text(&config.modules[i], mbuf, sizeof mbuf)) + mod_sp;
+		const char *out = module_text(m, mbuf, sizeof mbuf);
+		int tw = text_width(out);
+		XftColor *col = m->has_colour ? &m->xft_colour : &xft_fg;
+		XftDrawStringUtf8(d, col, font, cx, text_y, (const FcChar8 *)out, strlen(out));
+		cx += module_slot_width(m, tw) + mod_sp;
 	}
-	int ver_w = config.show_version ? text_width(config.version_text) : 0;
-	int mx = w - total_mw - ver_w - 2 * config.text_padding - 2 * pad;

+	/* right group: anchored to the right edge, before version text */
+	int rx = w - total_right - ver_w - 2 * config.text_padding - 2 * pad;
 	for (int i = 0; i < config.module_count; i++) {
-		if (!config.modules[i].enabled || !config.modules[i].cached_output)
+		Module *m = &config.modules[i];
+		if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary || m->align != ALIGN_RIGHT)
 			continue;
-		const char *out = module_text(&config.modules[i], mbuf, sizeof mbuf);
+		const char *out = module_text(m, mbuf, sizeof mbuf);
 		int tw = text_width(out);
-		XftColor *col = config.modules[i].has_colour
-		    ? &config.modules[i].xft_colour : &xft_fg;
-		XftDrawStringUtf8(d, col, font, mx, text_y, (const FcChar8 *)out, strlen(out));
-		mx += tw + mod_sp;
+		XftColor *col = m->has_colour ? &m->xft_colour : &xft_fg;
+		XftDrawStringUtf8(d, col, font, rx, text_y, (const FcChar8 *)out, strlen(out));
+		rx += module_slot_width(m, tw) + mod_sp;
 	}

-	/* version */
-	if (config.show_version) {
+	/* version (primary bar only) */
+	if (!is_secondary && config.show_version) {
 		int vx = w - ver_w - config.text_padding - pad;
 		XftDrawStringUtf8(d, &xft_fg, font, vx, text_y,
 		                  (const FcChar8 *)config.version_text,
@@ -386,12 +446,13 @@ static void draw_bar_into(Drawable draw, int monitor_index)
 	}
 }

-static void redraw_monitor(int i)
+static void redraw_bar(int bar_idx)
 {
-	int w = monitors[i].width - 2 * config.horizontal_padding;
+	Bar *bar = &bars[bar_idx];
+	int w = monitors[bar->monitor].width - 2 * config.horizontal_padding;
 	int h = config.height;
-	draw_bar_into(buffers[i], i);
-	XCopyArea(dpy, buffers[i], wins[i], gc, 0, 0, w, h, 0, 0);
+	draw_bar_into(bar_idx);
+	XCopyArea(dpy, bar->buffer, bar->win, gc, 0, 0, w, h, 0, 0);
 }

 int get_current_workspace(void)
@@ -438,6 +499,30 @@ char **get_workspace_name(int *count)
 	return NULL;
 }

+/* x position where the workspace switcher ends (and left-aligned modules
+ * begin) for a bar -- mirrors the accumulation in draw_bar_into()'s
+ * workspace loop without doing any of that loop's drawing work */
+static int workspace_end_x(int is_secondary, int pad, int ws_sp)
+{
+	int cur_x = config.text_padding + pad;
+	if (is_secondary)
+		return cur_x;
+
+	int name_count = 0;
+	char **names = get_workspace_name(&name_count);
+	if (!names)
+		return cur_x;
+
+	for (int i = 0; i < name_count; i++) {
+		char tmp[64];
+		snprintf(tmp, sizeof tmp, " %s ", names[i]);
+		cur_x += text_width(tmp) + ws_sp;
+		free(names[i]);
+	}
+	free(names);
+	return cur_x;
+}
+
 static void spawn(const char *cmd)
 {
 	pid_t pid = fork();
@@ -459,36 +544,53 @@ void hdl_button(XEvent *xev)
 	if (btn != Button1 && btn != Button4 && btn != Button5)
 		return;

-	int idx = find_window_monitor(xev->xbutton.window);
+	int bar_idx = find_bar(xev->xbutton.window);
+	Bar *bar = &bars[bar_idx];
+	int is_secondary = bar->is_secondary;
 	int x_click = xev->xbutton.x;
-	int w = monitors[idx].width - 2 * config.horizontal_padding;
-	const int pad = 5, mod_sp = 20;
+	int w = monitors[bar->monitor].width - 2 * config.horizontal_padding;
+	const int pad = 5, ws_sp = 10, mod_sp = 20;

 	char mbuf[256];
-	int total_mw = 0;
+	int total_left = 0, total_center = 0, total_right = 0;
 	for (int i = 0; i < config.module_count; i++) {
-		if (!config.modules[i].enabled || !config.modules[i].cached_output)
+		Module *m = &config.modules[i];
+		if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary)
 			continue;
-		total_mw += text_width(module_text(&config.modules[i], mbuf, sizeof mbuf)) + mod_sp;
+		int tw = text_width(module_text(m, mbuf, sizeof mbuf));
+		int slot = module_slot_width(m, tw) + mod_sp;
+		if (m->align == ALIGN_LEFT) total_left += slot;
+		else if (m->align == ALIGN_CENTER) total_center += slot;
+		else total_right += slot;
 	}
-	int ver_w = config.show_version ? text_width(config.version_text) : 0;
-	int mx = w - total_mw - ver_w - 2 * config.text_padding - 2 * pad;
-
-	for (int i = 0; i < config.module_count; i++) {
-		if (!config.modules[i].enabled || !config.modules[i].cached_output)
-			continue;
-		const char *out = module_text(&config.modules[i], mbuf, sizeof mbuf);
-		int tw = text_width(out);
-		if (x_click >= mx && x_click < mx + tw + mod_sp) {
-			if (btn == Button1 && config.modules[i].click_command)
-				spawn(config.modules[i].click_command);
-			else if (btn == Button4 && config.modules[i].scroll_up_command)
-				spawn(config.modules[i].scroll_up_command);
-			else if (btn == Button5 && config.modules[i].scroll_down_command)
-				spawn(config.modules[i].scroll_down_command);
-			return;
+	int ver_w = (!is_secondary && config.show_version) ? text_width(config.version_text) : 0;
+
+	/* starting x for each group, matching draw_bar_into() exactly */
+	int group_x[3];
+	group_x[ALIGN_LEFT]   = workspace_end_x(is_secondary, pad, ws_sp);
+	group_x[ALIGN_CENTER] = (w - total_center) / 2;
+	group_x[ALIGN_RIGHT]  = w - total_right - ver_w - 2 * config.text_padding - 2 * pad;
+
+	for (int align = 0; align < 3; align++) {
+		int mx = group_x[align];
+		for (int i = 0; i < config.module_count; i++) {
+			Module *m = &config.modules[i];
+			if (!m->enabled || !m->cached_output || m->on_secondary != is_secondary || m->align != align)
+				continue;
+			const char *out = module_text(m, mbuf, sizeof mbuf);
+			int tw = text_width(out);
+			int slot = module_slot_width(m, tw);
+			if (x_click >= mx && x_click < mx + slot + mod_sp) {
+				if (btn == Button1 && m->click_command)
+					spawn(m->click_command);
+				else if (btn == Button4 && m->scroll_up_command)
+					spawn(m->scroll_up_command);
+				else if (btn == Button5 && m->scroll_down_command)
+					spawn(m->scroll_down_command);
+				return;
+			}
+			mx += slot + mod_sp;
 		}
-		mx += tw + mod_sp;
 	}
 }

@@ -499,15 +601,15 @@ void hdl_dummy(XEvent *xev)

 void hdl_expose(XEvent *xev)
 {
-	int idx = find_window_monitor(xev->xexpose.window);
-	redraw_monitor(idx);
+	int idx = find_bar(xev->xexpose.window);
+	redraw_bar(idx);
 }

 void hdl_property(XEvent *xev)
 {
 	if (xev->xproperty.atom == XInternAtom(dpy, "_NET_CURRENT_DESKTOP", False)) {
-		for (int i = 0; i < nmonitors; i++) {
-			redraw_monitor(i);
+		for (int i = 0; i < nbars; i++) {
+			redraw_bar(i);
 		}
 	}
 }
@@ -527,13 +629,14 @@ void init_defaults(void)
 	config.font = strdup("monospace:size=10");
 	config.show_version = True;
 	config.version_text = strdup(SXBAR_VERSION);
+	config.secondary_bar = False;
 	init_modules();
 }

-int find_window_monitor(Window win)
+int find_bar(Window win)
 {
-	for (int i = 0; i < nmonitors; i++) {
-		if (wins[i] == win) {
+	for (int i = 0; i < nbars; i++) {
+		if (bars[i].win == win) {
 			return i;
 		}
 	}
@@ -619,8 +722,8 @@ void run(void)
 		time_t now = time(NULL);
 		if (now - last >= 1) {
 			update_modules();
-			for (int i = 0; i < nmonitors; i++) {
-				redraw_monitor(i);
+			for (int i = 0; i < nbars; i++) {
+				redraw_bar(i);
 			}
 			last = now;
 		}
@@ -661,6 +764,25 @@ char *run_command(const char *cmd)
 	return res ? res : strdup("");
 }

+/* wrap s in single quotes for safe embedding in a shell command line */
+static char *shell_quote(const char *s)
+{
+	size_t len = strlen(s);
+	char *out = malloc(len * 4 + 3);
+	char *p = out;
+	*p++ = '\'';
+	for (size_t i = 0; i < len; i++) {
+		if (s[i] == '\'') {
+			*p++ = '\''; *p++ = '\\'; *p++ = '\''; *p++ = '\'';
+		} else {
+			*p++ = s[i];
+		}
+	}
+	*p++ = '\'';
+	*p = '\0';
+	return out;
+}
+
 void update_modules(void)
 {
 	time_t now = time(NULL);
@@ -672,6 +794,18 @@ void update_modules(void)
 		if (now - m->last_update >= m->refresh_interval) {
 			free(m->cached_output);
 			m->cached_output = run_command(m->command);
+
+			if (m->prefix_command) {
+				char *quoted = shell_quote(m->cached_output);
+				size_t cmdlen = strlen(m->prefix_command) + strlen(quoted) + 2;
+				char *full = malloc(cmdlen);
+				snprintf(full, cmdlen, "%s %s", m->prefix_command, quoted);
+				free(quoted);
+				free(m->prefix_cached);
+				m->prefix_cached = run_command(full);
+				free(full);
+			}
+
 			m->last_update = now;
 		}
 	}