foxygit / sxbar Log in
commit 851573b36390f7279b9aea5c34d12e8e49203197
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Thu Jul 30 11:33:25 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Thu Jul 30 11:33:25 2026 +0200

    more!
---
 default_sxbarc | 21 ++++++++++-------
 docs/wiki.html | 23 ++++++++++++-------
 src/defs.h     |  5 ++++-
 src/parser.c   | 35 +++++++++++++++++++++++++++++
 src/sxbar.c    | 71 +++++++++++++++++++++++++++++++++++++++++++---------------
 sxbar.1        | 10 +++++----
 6 files changed, 126 insertions(+), 39 deletions(-)

diff --git a/default_sxbarc b/default_sxbarc
index abdf555..e44c983 100644
--- a/default_sxbarc
+++ b/default_sxbarc
@@ -268,7 +268,7 @@ module : taskbar    : false : 1
 # defaults to `hover` for a consistent feel, but `click` remains available
 # if you'd rather a menu only appear on a deliberate click:
 #   text    -- purely informational, not clickable at all (popup_info)
-#   button  -- runs its own command and closes the popup on click (popup_item)
+#   button  -- runs its own command on click; the popup stays open (popup_item)
 #   slider  -- a draggable 0-100% track; dragging doesn't close the popup (popup_set)
 #   image   -- renders an image file, e.g. album art; not clickable (popup_image)
 #   buttons -- one row split into N equal-width button segments side by
@@ -278,10 +278,10 @@ module : taskbar    : false : 1
 # popup : module_name : hover|click : buttons|slider
 #   hover opens the popup while the pointer is over the module (and closes
 #   it when the pointer leaves both the module and the popup); click opens
-#   it on left-click and closes it again on a second click, on clicking a
-#   button row, or on clicking anywhere else (like an ordinary dropdown
-#   menu). The third field only decides "does this module have a popup at
-#   all" these days -- popup_item/popup_info/popup_image/popup_buttons/
+#   it on left-click and closes it again on a second click, or on clicking
+#   anywhere else (like an ordinary dropdown menu). The third field only
+#   decides "does this module have a popup at all" these days --
+#   popup_item/popup_info/popup_image/popup_buttons/
 #   popup_set decide what's in it, in any combination, regardless of which
 #   of `buttons`/`slider` you put here, so either word works.
 #
@@ -300,17 +300,22 @@ module : taskbar    : false : 1
 # popup_image : module_name : "shell command"   -- one image row. The
 #   command's stdout is a path to a local image file, re-run fresh every
 #   time the popup opens (e.g. current track's album art); scaled to fit a
-#   160px box, preserving aspect ratio. Empty/failed output just means no
-#   image that time -- the row stays, it renders blank. Not clickable.
+#   160px box (see popup_image_size below to change that), preserving
+#   aspect ratio. Empty/failed output just means no image that time -- the
+#   row stays, it renders blank. Not clickable.
 #   No extra build dependency -- image decoding is a vendored, compiled-in
 #   library (see README). An image (or slider, or buttons) row anchors
 #   the popup's width -- any popup_info/popup_item text in the same popup
 #   is then capped to that width and scrolls (marquee) if it's longer,
 #   instead of stretching the popup past the image.
 #
+# popup_image_size : module_name : pixels   -- changes the square box a
+#   module's popup_image row(s) scale their art into (default: 160).
+#   Example: popup_image_size : media : 220
+#
 # popup_buttons : module_name : "Label1" : "cmd1" : "Label2" : "cmd2" ...
 #   one row split into N equal-width button segments, each running its own
-#   command and closing the popup on click -- same behaviour as popup_item
+#   command on click; the popup stays open -- same behaviour as popup_item
 #   but laid out side by side instead of stacked. Needs an even number of
 #   quoted label/command pairs; glyphs work well as labels here (see the
 #   media example below) so you get an icon row instead of stacked text.
diff --git a/docs/wiki.html b/docs/wiki.html
index f634ee9..b031c0c 100644
--- a/docs/wiki.html
+++ b/docs/wiki.html
@@ -835,8 +835,8 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
         <div class="card">
           <div class="eyebrow">click</div>
           <h4>Opens on left-click</h4>
-          <p>Toggles on click; closes again on a second click, on clicking a button
-          row, or on clicking anywhere else — like an ordinary dropdown menu.</p>
+          <p>Toggles on click; closes again on a second click, or on clicking
+          anywhere else — like an ordinary dropdown menu.</p>
         </div>
       </div>
       <div class="note">Hovering only <em>reveals</em> a menu — an actual click on a
@@ -859,7 +859,7 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
         <div class="card">
           <div class="eyebrow">button</div>
           <h4>popup_item</h4>
-          <p>Runs its own command and closes the popup on click.</p>
+          <p>Runs its own command on click; the popup stays open.</p>
         </div>
         <div class="card">
           <div class="eyebrow">slider</div>
@@ -871,10 +871,12 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
           <div class="eyebrow">image</div>
           <h4>popup_image</h4>
           <p>Renders an image file, e.g. album art. Command's stdout is a path to a
-          local image, re-run fresh every popup open, scaled to fit a 160px box.
-          Not clickable. No extra build dependency needed. Anchors the popup's
-          width — any text row in the same popup is capped to it and scrolls
-          instead of stretching the popup wider than the image.</p>
+          local image, re-run fresh every popup open, scaled to fit a 160px box
+          by default — override per module with
+          <code class="inline">popup_image_size</code>. Not clickable. No extra
+          build dependency needed. Anchors the popup's width — any text row in
+          the same popup is capped to it and scrolls instead of stretching the
+          popup wider than the image.</p>
         </div>
         <div class="card">
           <div class="eyebrow">buttons</div>
@@ -882,13 +884,14 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
           <p>One row split into N equal-width button segments side by side (e.g.
           media transport controls), instead of N stacked full-width
           <code class="inline">popup_item</code> rows. Each segment runs its own
-          command and closes the popup on click.</p>
+          command on click; the popup stays open.</p>
         </div>
       </div>

       <pre><span class="k">popup_item</span>    : module_name : <span class="s">"Label"</span> : <span class="s">"command"</span>     <span class="c"># button row, repeatable</span>
 <span class="k">popup_info</span>    : module_name : <span class="s">"shell command"</span>              <span class="c"># text row, repeatable</span>
 <span class="k">popup_image</span>   : module_name : <span class="s">"shell command"</span>              <span class="c"># image row, stdout = path to an image file</span>
+<span class="k">popup_image_size</span> : module_name : pixels                    <span class="c"># image row box size, default 160</span>
 <span class="k">popup_buttons</span> : module_name : <span class="s">"L1"</span> : <span class="s">"cmd1"</span> : <span class="s">"L2"</span> : <span class="s">"cmd2"</span> ...  <span class="c"># one row, N button segments</span>
 <span class="k">popup_set</span>     : module_name : <span class="s">"command"</span>                    <span class="c"># slider row, receives new value as $1 (e.g. "45%")</span></pre>

@@ -1005,6 +1008,9 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
 <span class="k">popup_image   </span>: media : <span class="s">"~/.config/sxbar/scripts/media.sh art"</span>
 <span class="k">popup_info    </span>: media : <span class="s">"~/.config/sxbar/scripts/media.sh track"</span>
 <span class="k">popup_buttons </span>: media : <span class="s">"&lt;step-backward&gt;"</span> : <span class="s">"~/.config/sxbar/scripts/media.sh prev"</span> : <span class="s">"&lt;play&gt;"</span> : <span class="s">"~/.config/sxbar/scripts/media.sh playpause"</span> : <span class="s">"&lt;step-forward&gt;"</span> : <span class="s">"~/.config/sxbar/scripts/media.sh next"</span></pre>
+      <p>Bigger album art? Add
+      <code class="inline">popup_image_size : media : 220</code> alongside the
+      lines above — any pixel size, default is 160.</p>
     </section>

     <section id="example">
@@ -1078,6 +1084,7 @@ status=$(cat /sys/class/power_supply/BAT*/status 2&gt;/dev/null | head -n1)
             <tr><td class="mono">popup_item</td><td class="mono">popup_item : name : "Label" : "command"</td></tr>
             <tr><td class="mono">popup_info</td><td class="mono">popup_info : name : "shell command"</td></tr>
             <tr><td class="mono">popup_image</td><td class="mono">popup_image : name : "shell command"</td></tr>
+            <tr><td class="mono">popup_image_size</td><td class="mono">popup_image_size : name : pixels</td></tr>
             <tr><td class="mono">popup_set</td><td class="mono">popup_set : name : "command"</td></tr>
             <tr><td class="mono">height / bottom_bar / *_padding / border*</td><td>global bar geometry — see <a href="#bars">Global &amp; bar options</a></td></tr>
             <tr><td class="mono">background_colour / foreground_colour / border_colour / font</td><td>global appearance</td></tr>
diff --git a/src/defs.h b/src/defs.h
index d528e09..d11b2aa 100644
--- a/src/defs.h
+++ b/src/defs.h
@@ -28,7 +28,7 @@
 #define POPUP_TRIGGER_HOVER 1

 #define POPUP_ROW_TEXT    0 /* purely informational -- not clickable at all */
-#define POPUP_ROW_BUTTON  1 /* spawns command and closes the popup on click */
+#define POPUP_ROW_BUTTON  1 /* spawns command; popup stays open on click */
 #define POPUP_ROW_SLIDER  2 /* draggable 0-100% track; doesn't close on click/drag */
 #define POPUP_ROW_IMAGE   3 /* renders an image file (e.g. album art); not clickable */
 #define POPUP_ROW_BUTTONS 4 /* one row split into N equal-width button segments,
@@ -114,6 +114,9 @@ typedef struct Module {
 	PopupItem *popup_items;
 	int popup_item_count;
 	int popup_item_max;
+	int popup_image_size; /* IMAGE rows scale to fit within this square box,
+	                       * in pixels; 0 = built-in default (see
+	                       * POPUP_IMAGE_SIZE in sxbar.c) */
 	int popup_items_from_config; /* config popup_item/popup_info lines replace the built-in defaults, once */
 	int slider_item_idx;          /* which popup_items[] entry popup_set manages, or -1 */
 	/* the built-in `taskbar` module only: renders one clickable segment
diff --git a/src/parser.c b/src/parser.c
index 8559200..953b00a 100644
--- a/src/parser.c
+++ b/src/parser.c
@@ -351,6 +351,21 @@ static int apply_popup_image(Module *m, char *rest, const char *ctx, int lineno,
 	return 0;
 }

+static int apply_popup_image_size(Module *m, char *rest, const char *ctx, int lineno)
+{
+	/* rest: pixels -- caps the square box popup_image rows scale their art
+	 * into for this module (default: the built-in POPUP_IMAGE_SIZE) */
+	char *val = strip(rest);
+	strip_comment(val);
+	int px = atoi(val);
+	if (px <= 0) {
+		fprintf(stderr, "%s:%d: popup_image_size must be a positive number\n", ctx, lineno);
+		return -1;
+	}
+	m->popup_image_size = px;
+	return 0;
+}
+
 /* free a partially- or fully-built button array on a parse error, before
  * returning -- nothing has been attached to a Module yet at that point */
 static void free_parsed_buttons(PopupButton *btns, int count)
@@ -544,6 +559,8 @@ void load_popup_from_script(Module *m, const char *script_path)
 			apply_popup_info(m, rest, ctx, lineno, 0);
 		} else if (!strcmp(key, "popup_image")) {
 			apply_popup_image(m, rest, ctx, lineno, 0);
+		} else if (!strcmp(key, "popup_image_size")) {
+			apply_popup_image_size(m, rest, ctx, lineno);
 		} else if (!strcmp(key, "popup_buttons")) {
 			apply_popup_buttons(m, rest, ctx, lineno, 0);
 		} else if (!strcmp(key, "popup_set")) {
@@ -941,6 +958,24 @@ int parse_config(Config *cfg)
 				continue;
 			}
 			apply_popup_image(m, after, "sxbarc", lineno, 1);
+		} else if (!strcmp(key, "popup_image_size")) {
+			/* popup_image_size : module_name : pixels -- caps the square box
+			 * this module's popup_image rows scale their art into (default:
+			 * the built-in POPUP_IMAGE_SIZE, currently 160) */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: popup_image_size missing name and value\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name = strip(rest);
+			char *val  = strip(p1 + 1);
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: popup_image_size: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			apply_popup_image_size(m, val, "sxbarc", lineno);
 		} else if (!strcmp(key, "popup_buttons")) {
 			/* popup_buttons : module_name : "Label1" : "command1" : "Label2" :
 			 * "command2" ... -- one row split into N equal-width button
diff --git a/src/sxbar.c b/src/sxbar.c
index d747710..1bc62e0 100644
--- a/src/sxbar.c
+++ b/src/sxbar.c
@@ -40,6 +40,7 @@ void hdl_dummy(XEvent *xev);
 void hdl_expose(XEvent *xev);
 void hdl_motion(XEvent *xev);
 void hdl_property(XEvent *xev);
+void hdl_visibility(XEvent *xev);
 void init_defaults(void);
 void init_modules(void);
 unsigned long parse_col(const char *hex);
@@ -365,8 +366,8 @@ static XImage *rgba_to_ximage(const unsigned char *rgba, int w, int h)
 }

 /* decode `path` (any format stb_image supports -- JPEG/PNG/GIF/BMP/...),
- * scale it down (preserving aspect ratio) to fit within a `box`-pixel
- * square if larger, and return it as an XImage ready for XPutImage().
+ * scale it (preserving aspect ratio, up or down) to fit within a
+ * `box`-pixel square, and return it as an XImage ready for XPutImage().
  * NULL on any failure (missing/corrupt/unreadable file). */
 static void *load_scaled_image(const char *path, int box, int *out_w, int *out_h)
 {
@@ -375,12 +376,9 @@ static void *load_scaled_image(const char *path, int box, int *out_w, int *out_h
 	if (!pixels)
 		return NULL;

-	int dw = iw, dh = ih;
-	if (iw > box || ih > box) {
-		double scale = iw > ih ? (double)box / iw : (double)box / ih;
-		dw = (int)(iw * scale); if (dw < 1) dw = 1;
-		dh = (int)(ih * scale); if (dh < 1) dh = 1;
-	}
+	double scale = iw > ih ? (double)box / iw : (double)box / ih;
+	int dw = (int)(iw * scale); if (dw < 1) dw = 1;
+	int dh = (int)(ih * scale); if (dh < 1) dh = 1;

 	unsigned char *final = pixels;
 	int scaled_ourselves = 0;
@@ -1007,12 +1005,12 @@ static int popup_row_height(void)
 	return font->ascent + font->descent + 2 * POPUP_ROW_PAD;
 }

-static int popup_item_height(PopupItem *it)
+static int popup_item_height(PopupItem *it, int img_size)
 {
 	if (it->type == POPUP_ROW_SLIDER)
 		return popup_row_height() + POPUP_ROW_PAD + POPUP_TRACK_H + POPUP_ROW_PAD;
 	if (it->type == POPUP_ROW_IMAGE)
-		return (it->image_h > 0 ? it->image_h : POPUP_IMAGE_SIZE) + 2 * POPUP_ROW_PAD;
+		return (it->image_h > 0 ? it->image_h : img_size) + 2 * POPUP_ROW_PAD;
 	return popup_row_height();
 }

@@ -1020,27 +1018,30 @@ static int popup_item_height(PopupItem *it)
  * and slider rows can all be mixed, each with its own height) */
 static int popup_total_height(Module *m)
 {
+	int img_size = m->popup_image_size > 0 ? m->popup_image_size : POPUP_IMAGE_SIZE;
 	int h = 0;
 	for (int i = 0; i < m->popup_item_count; i++)
-		h += popup_item_height(&m->popup_items[i]);
+		h += popup_item_height(&m->popup_items[i], img_size);
 	return h > 0 ? h : popup_row_height();
 }

 /* y offset (popup-window-relative) where row idx starts */
 static int popup_row_y(Module *m, int idx)
 {
+	int img_size = m->popup_image_size > 0 ? m->popup_image_size : POPUP_IMAGE_SIZE;
 	int y = 0;
 	for (int i = 0; i < idx; i++)
-		y += popup_item_height(&m->popup_items[i]);
+		y += popup_item_height(&m->popup_items[i], img_size);
 	return y;
 }

 /* which row (if any) contains popup-window-relative y, or -1 */
 static int popup_row_at_y(Module *m, int y)
 {
+	int img_size = m->popup_image_size > 0 ? m->popup_image_size : POPUP_IMAGE_SIZE;
 	int cy = 0;
 	for (int i = 0; i < m->popup_item_count; i++) {
-		int rh = popup_item_height(&m->popup_items[i]);
+		int rh = popup_item_height(&m->popup_items[i], img_size);
 		if (y >= cy && y < cy + rh)
 			return i;
 		cy += rh;
@@ -1142,7 +1143,7 @@ static void popup_draw(void)
 		int avail = popup.w - 2 * POPUP_PAD;
 		int lw = text_width(label);
 		if (it->type == POPUP_ROW_BUTTON && i == popup.hover_row) {
-			int row_h = popup_item_height(it);
+			int row_h = popup_item_height(it, m->popup_image_size > 0 ? m->popup_image_size : POPUP_IMAGE_SIZE);
 			XSetForeground(dpy, gc, config.foreground_colour);
 			XFillRectangle(dpy, popup.buffer, gc, 0, ry, popup.w, row_h);
 			draw_ticker(popup.xft_draw, &xft_bg, POPUP_PAD, text_y, avail, label, lw, it->scroll_offset);
@@ -1247,7 +1248,8 @@ static void popup_open(int bar_idx, Module *m, int anchor_x)
 			char *path = it->image_command ? run_command(it->image_command) : NULL;
 			if (path && *path) {
 				int iw, ih;
-				it->image = load_scaled_image(path, POPUP_IMAGE_SIZE, &iw, &ih);
+				int img_size = m->popup_image_size > 0 ? m->popup_image_size : POPUP_IMAGE_SIZE;
+				it->image = load_scaled_image(path, img_size, &iw, &ih);
 				if (it->image) {
 					it->image_w = iw;
 					it->image_h = ih;
@@ -1288,7 +1290,7 @@ static void popup_open(int bar_idx, Module *m, int anchor_x)
 	    .override_redirect = True,
 	    .background_pixel = config.background_colour,
 	    .event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask |
-	                  PointerMotionMask | LeaveWindowMask,
+	                  PointerMotionMask | LeaveWindowMask | VisibilityChangeMask,
 	};
 	Window win = XCreateWindow(dpy, root, x, y, w, h, 0, CopyFromParent, InputOutput,
 	                           DefaultVisual(dpy, scr),
@@ -1345,9 +1347,11 @@ static void popup_handle_button(XEvent *xev)
 	PopupItem *it = &m->popup_items[row];
 	switch (it->type) {
 	case POPUP_ROW_BUTTON:
+		/* run the command but leave the popup open -- it only closes once
+		 * the pointer actually leaves the popup/bar area (hdl_crossing) or
+		 * the user clicks outside it (hdl_button) */
 		if (it->command && *it->command)
 			spawn(it->command);
-		popup_close();
 		break;
 	case POPUP_ROW_SLIDER:
 		popup.dragging_row = row;
@@ -1360,7 +1364,6 @@ static void popup_handle_button(XEvent *xev)
 			seg = it->button_count - 1;
 		if (seg >= 0 && it->buttons[seg].command && *it->buttons[seg].command)
 			spawn(it->buttons[seg].command);
-		popup_close();
 		break;
 	}
 	default:
@@ -1500,6 +1503,23 @@ void hdl_crossing(XEvent *xev)
 		bar_root_geometry(popup.bar_idx, &bx, &by, &bw, &bh);
 		if (root_x >= bx && root_x < bx + bw && root_y >= by && root_y < by + bh)
 			return;
+
+		/* the thin POPUP_GAP strip between the bar and the popup -- an
+		 * imprecise/fast mouse move can land there for a frame on its way
+		 * from one to the other. Tolerate it (same x-range as the popup)
+		 * instead of treating it as "left both", so the visual gap stays
+		 * but doesn't act as dead space that closes the popup. */
+		int gap_y0, gap_y1;
+		if (popup.y + popup.h <= by) {
+			gap_y0 = popup.y + popup.h;
+			gap_y1 = by;
+		} else {
+			gap_y0 = by + bh;
+			gap_y1 = popup.y;
+		}
+		if (root_x >= popup.x && root_x < popup.x + popup.w &&
+		    root_y >= gap_y0 && root_y < gap_y1)
+			return;
 	}
 	popup_close();
 }
@@ -1515,6 +1535,20 @@ void hdl_expose(XEvent *xev)
 	redraw_bar(idx);
 }

+/* the popup is an override-redirect window, so the window manager never
+ * restacks it on our behalf -- if some other (WM-managed) window gets
+ * raised while the popup is open (e.g. focus-follows-mouse raising the
+ * window the pointer just passed over on its way to the next module),
+ * that window ends up on top of the popup even though we raised it first.
+ * Re-raise whenever we notice we've been covered. */
+void hdl_visibility(XEvent *xev)
+{
+	if (!popup.open || xev->xvisibility.window != popup.win)
+		return;
+	if (xev->xvisibility.state != VisibilityUnobscured)
+		XRaiseWindow(dpy, popup.win);
+}
+
 void hdl_property(XEvent *xev)
 {
 	if (xev->xproperty.atom == XInternAtom(dpy, "_NET_CURRENT_DESKTOP", False)) {
@@ -1875,6 +1909,7 @@ void setup(void)
 	evtable[MotionNotify] = hdl_motion;
 	evtable[LeaveNotify] = hdl_crossing;
 	evtable[PropertyNotify] = hdl_property;
+	evtable[VisibilityNotify] = hdl_visibility;
 	XSelectInput(dpy, root, PropertyChangeMask);

 	init_defaults();
diff --git a/sxbar.1 b/sxbar.1
index 7d4355d..0d89393 100644
--- a/sxbar.1
+++ b/sxbar.1
@@ -331,8 +331,8 @@ command. Only one popup is open at a time.
 opens the popup while the pointer is over the module, closing it when the
 pointer leaves both the module and the popup;
 .B click
-opens it on left-click and closes it again on a second click, on clicking a
-button row, or on clicking elsewhere. The third field only decides whether
+opens it on left-click and closes it again on a second click, or on clicking
+elsewhere. The third field only decides whether
 the module has a popup at all \(en
 .BR popup_item / popup_info / popup_set
 below decide what is actually in it, in any combination, so either word
@@ -341,7 +341,9 @@ works there.
 .BR "popup_item : name : \(dqLabel\(dq : \(dqcommand\(dq"
 A button row: runs
 .I command
-(detached) and closes the popup, on click. Repeatable.
+(detached) on click; the popup stays open (see
+.B POPUPS
+above for how it closes). Repeatable.
 .TP
 .BR "popup_info : name : \(dqcommand\(dq"
 A purely informational text row: its label is
@@ -372,7 +374,7 @@ One row split into N equal-width button segments side by side, e.g. media
 transport controls, instead of N stacked full-width
 .B popup_item
 rows. Needs an even number of quoted label/command pairs; each segment
-runs its own command and closes the popup on click, same as
+runs its own command on click and the popup stays open, same as
 .BR popup_item .
 Glyphs work well as segment labels here.
 .TP