foxygit / sxbar Log in
commit 049ee83b164abf637e8425a1d3441ce2c9e30eab
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Tue Jul 28 21:50:21 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Tue Jul 28 21:50:21 2026 +0200

    more addons
---
 README.md             | 206 +++++++++++++++++
 default_sxbarc        | 151 ++++++++++--
 scripts/demo_popup.sh |  13 ++
 src/defs.h            |  54 +++++
 src/parser.c          | 255 ++++++++++++++++++++
 src/sxbar.c           | 627 ++++++++++++++++++++++++++++++++++++++++++++++++--
 6 files changed, 1266 insertions(+), 40 deletions(-)

diff --git a/README.md b/README.md
index 467caac..e175770 100644
--- a/README.md
+++ b/README.md
@@ -364,6 +364,212 @@ pixel sampling (`#000000`/`#7abccd` rendered exact), but a trailing
 `XParseColor` and fail (falls back to white, with a stderr warning).
 Comment on its own line instead for now.

+## Floating popups: brightness slider, bluetooth menu, user menu
+
+Three built-in modules can now open a small floating (override-redirect)
+window instead of just running a click command: `brightness` reveals a
+slider on hover, `bluetooth` and `usermenu` open a button menu on click.
+
+### Changes
+- Added a generic popup subsystem: at most one floating window open at a
+  time, drawn with the bar's own font/colours. Added `Popup` (`src/defs.h`)
+  holding its runtime state, and `PopupItem` for button-menu rows.
+- Added `popup_type` (`POPUP_BUTTONS`/`POPUP_SLIDER`), `popup_trigger`
+  (`POPUP_TRIGGER_HOVER`/`POPUP_TRIGGER_CLICK`), a `popup_items` array and
+  `popup_set_command` to `Module`.
+- Added `PointerMotionMask`/`LeaveWindowMask` to the bar windows and new
+  event handlers -- `hdl_motion`, `hdl_crossing`, `hdl_button_release` --
+  registered alongside the existing `hdl_button`/`hdl_expose`.
+- Hovering a `hover`-triggered module opens its popup; moving the pointer
+  off both the module and the popup closes it. Clicking a `click`-triggered
+  module toggles its popup; clicking a button row runs that row's command
+  and closes it; clicking anywhere else (including other applications --
+  caught via a pointer grab held while the popup is open) also closes it,
+  like an ordinary dropdown menu.
+- The slider drags via the same pointer grab (`PointerMotionMask` +
+  `ButtonReleaseMask`), spawning `popup_set_command` with the new value
+  (as `$1`, e.g. `"45%"`) only when the value actually changes.
+- Three new built-in modules, all opt-in (`enabled : false` by default,
+  except `usermenu`):
+  - `brightness` -- reads `brightnessctl -m`; hover slider drags call
+    `brightnessctl set`.
+  - `bluetooth` -- shows adapter power state via `bluetoothctl show`;
+    click menu has Turn on/off, Search for devices, and Pair last found
+    device (`bluetoothctl pair`/`trust`/`connect` on the most recently
+    seen device -- no interactive device list).
+  - `usermenu` -- shows the current username (`whoami`); click menu has
+    Sleep (`systemctl suspend`), Log out, and Shut down
+    (`systemctl poweroff`). The default logout command is `pkill sxwm`,
+    since minimal WMs without a session manager have no external way to
+    trigger their own quit keybind -- override via `popup_item` in
+    `sxbarc` if your WM/session needs something else.
+
+### Config syntax
+
+```
+popup      : module_name : hover|click : buttons|slider
+popup_item : module_name : "Label" : "command"      # buttons, repeatable
+popup_set  : module_name : "command"                # slider only, $1 = new value
+```
+
+`popup_item` is additive after the first line for a module clears its
+built-in defaults, so a config can fully replace `bluetooth`'s or
+`usermenu`'s menu, or extend a custom module with its own button popup.
+
+### Example
+
+```
+module : brightness : true : 5
+module : bluetooth  : true : 10
+
+popup_item : usermenu : "Lock" : "slock"   # replaces the default 3 rows
+popup_item : usermenu : "Sleep" : "systemctl suspend"
+popup_item : usermenu : "Log out" : "pkill sxwm"
+popup_item : usermenu : "Shut down" : "systemctl poweroff"
+```
+
+### Result
+
+- No new build dependencies -- the popups are plain Xlib windows sharing
+  the bar's existing Xft font/colour setup.
+- `brightnessctl`/`bluetoothctl` are only needed at runtime by the modules
+  that shell out to them, same as `wpctl`/`playerctl` for other modules.
+
+## Volume slider and a network popup with WiFi/Ethernet + IPs
+
+Extends the floating-popup work above with a second slider (`volume`,
+alongside `brightness`) and a new `network` module whose popup shows live
+connection info instead of running actions.
+
+### Changes
+- `volume` now opens the same hover slider as `brightness` -- dragging it
+  spawns `wpctl set-volume @DEFAULT_AUDIO_SINK@ NN%`.
+- Added `PopupItem.label_command` (`src/defs.h`): when set, a buttons-popup
+  row's label is re-run fresh (`run_command()`) every time the popup opens
+  instead of using a fixed string, and clicking the row just closes the
+  popup (no action) -- for informational rows like "current IP".
+- Added the `popup_info : module_name : "shell command"` config directive
+  (`src/parser.c`), the config-side counterpart to `label_command`. It
+  shares the same "first line clears the built-in defaults, later lines
+  append" rule as `popup_item`, via a `clear_builtin_popup_items()` helper
+  both directives now go through.
+- New built-in `network` module: bar text is the default route's interface
+  name (or "Offline"); its click popup has two informational rows --
+  first interface with a `/sys/class/net/<if>/wireless` directory for
+  WiFi, first non-virtual `ARPHRD_ETHER` interface for Ethernet -- each
+  showing its IPv4 address (`ip -4 -o addr show`) or "disconnected" if it
+  has none. No interactive network switching, just a status readout.
+
+### Config syntax
+
+```
+popup_info : module_name : "shell command"   # label = command's output, re-run on every open
+```
+
+### Example
+
+```
+module : volume  : true : 5
+module : network : true : 10
+
+popup_info : network : "curl -s ifconfig.me | awk '{print \"Public IP: \"$0}'"
+```
+
+## icon_only, and a hover popup on cpu
+
+Two small additions: a way to hide a module's own text and show just its
+icon, and proof that a `buttons` popup works on `hover` just as well as
+`slider` does (previously only shown paired with `click`).
+
+### Changes
+- Added `Module.icon_only` (`src/defs.h`) and the `icon_only : module_name
+  : true|false` config directive. `module_text()` now returns just the
+  prefix (or `""` if none set) when it's on, instead of prefix+output --
+  the module's command, refresh_interval and popup all keep running
+  exactly as before, only the rendered bar text changes.
+- `cpu` now opens a `buttons`+`hover` popup with three informational rows
+  (`popup_info`, see previous section) -- a fresh CPU-usage sample,
+  `free -h`'s memory line, and a per-core load breakdown (two `/proc/stat`
+  samples paired up by position via an awk array, since `/bin/sh` may be
+  `dash` and can't do process substitution) -- demonstrating that
+  `popup_type`/`popup_trigger` are independent axes: any combination of
+  `buttons`/`slider` with `hover`/`click` is valid, not just the pairings
+  the other built-ins happen to use.
+
+### Config syntax
+
+```
+icon_only : module_name : true|false
+```
+
+### Example
+
+```
+prefix    : network : " "
+icon_only : network : true   # bar shows just the icon; click still opens the popup
+```
+
+## Row-based popups (text/button/slider mixed freely), and hover by default
+
+Popups were previously one whole type per module -- a module was either a
+`buttons` list or a `slider`, never both, and every row in a `buttons`
+popup was clickable (even the "informational" `popup_info` ones, which
+just closed the popup for no reason on click). This reworks popups to be
+a list of independently-typed rows instead, and switches every built-in
+popup to open on `hover`.
+
+### Changes
+- `PopupItem` (`src/defs.h`) now carries its own `.type` --
+  `POPUP_ROW_TEXT`, `POPUP_ROW_BUTTON`, or `POPUP_ROW_SLIDER` -- instead
+  of the whole popup having one `Module.popup_type`. `popup_type` on
+  `Module` now only means "this module has a popup at all"; `POPUP_SLIDER`
+  as a per-module type is gone.
+- `popup_info` rows are now genuinely inert: clicking one does nothing at
+  all (previously it closed the popup, which read as a fake button).
+  `popup_item`/`popup_info`/`popup_set` can all target the same module and
+  coexist in one popup -- `popup_set` manages a single dedicated slider row
+  per module (tracked via `Module.slider_item_idx`), independent of
+  whatever text/button rows also exist there.
+- Popup geometry (`popup_row_height`/`popup_item_height`/
+  `popup_total_height`/`popup_row_y`/`popup_row_at_y`) is now computed
+  per-row instead of assuming a uniform row height or a single slider
+  filling the whole window, since a slider row is taller than a text/button
+  row. Dragging (`Popup.dragging_row`) and hover-highlighting
+  (`Popup.hover_row`, buttons only) are tracked by row index rather than
+  assuming the whole popup is one slider or one button list.
+- `bluetooth`, `usermenu` and `network` switched from `click` to `hover`
+  triggers, matching `brightness`/`volume`/`cpu` -- every built-in popup
+  now opens the same way. Hovering only reveals the menu; an actual click
+  on a button row is still needed to run anything, so this doesn't make
+  destructive actions (shutdown, logout) any easier to trigger by
+  accident. Override back to `click` per-module with `popup : module_name
+  : click : buttons` if you'd rather.
+- `demo_menu` (the try-it-yourself example) now combines all three row
+  kinds in one popup instead of being split across `demo_menu` (buttons)
+  and a separate `demo_slider`.
+
+### Config syntax
+
+Unchanged -- `popup_item`/`popup_info`/`popup_set` already existed; what's
+new is that they can all apply to the same module at once:
+
+```
+popup      : module_name : hover|click : buttons|slider   # third field just means "has a popup"
+popup_info : module_name : "shell command"                 # text row
+popup_item : module_name : "Label" : "command"              # button row
+popup_set  : module_name : "command"                        # slider row
+```
+
+### Example
+
+```
+custom     : mymodule : "echo ok" : 5
+popup      : mymodule : hover : buttons
+popup_info : mymodule : "echo 'status: '$(whoami)"
+popup_item : mymodule : "Restart" : "systemctl --user restart myservice"
+popup_set  : mymodule : "myservice-set-level"
+```
+
 ## Disk footprint

 - `sxbar` is very lightweight: the compiled binary is about 36 KB.
diff --git a/default_sxbarc b/default_sxbarc
index 2cd2464..d0f111e 100644
--- a/default_sxbarc
+++ b/default_sxbarc
@@ -33,12 +33,23 @@ font                : monospace:size=8

 # Built-in modules
 # module : name : enabled : refresh_interval_seconds
-# Available: clock, date, battery, volume, cpu
-module : clock   : true  : 1
-module : date    : true  : 60
-module : battery : false : 30
-module : volume  : true  : 5
-module : cpu     : false : 3
+# Available: clock, date, battery, volume, cpu, brightness, bluetooth,
+#            usermenu, network
+module : clock      : true  : 1
+module : date       : true  : 60
+module : battery    : false : 30
+# volume: hover over it to reveal a slider (needs wpctl)
+module : volume     : true  : 5
+# cpu: hover over it to see more detail (cpu, memory, per-core load) in a popup
+module : cpu        : false : 3
+# brightness: hover over it to reveal a slider (needs brightnessctl)
+module : brightness : false : 5
+# bluetooth: hover over it to reveal a floating menu (needs bluetoothctl)
+module : bluetooth  : false : 10
+# usermenu: hover over it to reveal a floating sleep/log out/shut down menu
+module : usermenu   : true  : 300
+# network: hover over it to reveal a floating menu showing WiFi/Ethernet + IPs (needs ip)
+module : network    : false : 10

 # Custom script modules (like polybar exec)
 # Command must be quoted. Output is displayed in the bar.
@@ -47,7 +58,7 @@ module : cpu     : false : 3
 # Examples:
 # custom : temp     : "sensors | grep 'Package' | awk '{print $4}'" : 5
 # custom : mem      : "free -h | awk '/^Mem:/{print $3\"/\"$2}'" : 10
-# custom : network  : "~/.config/sxbar/scripts/network.sh" : 5
+# custom : netscript : "~/.config/sxbar/scripts/network.sh" : 5
 # custom : updates  : "checkupdates | wc -l | tr -d ' '" : 300

 # Prefix / icon — text prepended to a module's output (works for both
@@ -57,11 +68,15 @@ module : cpu     : false : 3
 # (icon : ... is accepted as an alias for prefix)
 #
 # Examples:
-# prefix : clock   : " "
-# prefix : date    : " "
-# prefix : battery : " "
-# prefix : volume  : " "
-# prefix : cpu     : " "
+# prefix : clock      : " "
+# prefix : date       : " "
+# prefix : battery    : " "
+# prefix : volume     : " "
+# prefix : cpu        : " "
+# prefix : brightness : " "
+# prefix : bluetooth  : " "
+# prefix : usermenu   : " "
+# prefix : network    : " "

 # 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.
@@ -83,6 +98,18 @@ module : cpu     : false : 3
 # and are installed to $(PREFIX)/share/sxbar/scripts/ -- copy them to
 # ~/.config/sxbar/scripts/ (the path above) and edit from there.

+# Icon only -- hides a module's own text, showing just its prefix/icon.
+# Needs a prefix/prefix_cmd set (see above) or there's nothing left to show.
+# The module's command/refresh_interval/popup all still run as normal --
+# only the bar's rendered text changes.
+# icon_only : module_name : true|false
+#
+# Example (network's bar text becomes just its icon; "Online"/"Offline"
+# is still computed underneath, it's just not drawn -- click the icon for
+# the WiFi/Ethernet popup as usual):
+# prefix     : network : " "
+# icon_only  : network : true
+
 # 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
@@ -124,10 +151,15 @@ module : cpu     : false : 3
 # colour : module_name : #rrggbb
 #
 # Examples:
-# colour : clock   : #50fa7b
-# colour : volume  : #ff79c6
-# colour : battery : #ffb86c
-# colour : date    : #8be9fd
+# colour : clock      : #50fa7b
+# colour : volume     : #ff79c6
+# colour : battery    : #ffb86c
+# colour : date       : #8be9fd
+# colour : cpu        : #bd93f9
+# colour : brightness : #f1fa8c
+# colour : bluetooth  : #4a9eff
+# colour : usermenu   : #ff5555
+# colour : network    : #2ee6d6

 # Click commands — run a command when a module is left-clicked
 # Works for both built-in modules and custom modules
@@ -137,7 +169,7 @@ module : cpu     : false : 3
 # click : volume  : "pavucontrol"
 # click : clock   : "xclock"
 # click : battery : "xterm -e 'upower -i /org/freedesktop/UPower/devices/battery_BAT0; read'"
-# click : network : "xterm -e nmtui"
+# click : netscript : "xterm -e nmtui"     # or use the built-in `network` module's popup instead
 #
 # Scroll wheel — scroll up/down over a module to run a command
 # scroll_up   : module_name : "command"
@@ -147,6 +179,91 @@ module : cpu     : false : 3
 # scroll_up   : volume : "wpctl set-volume --limit 1.0 @DEFAULT_AUDIO_SINK@ 5%+"
 # scroll_down : volume : "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"

+# Floating popups -- a module can open a small floating window instead of
+# (or as well as) running a plain click_command. A popup is just a list of
+# rows, and text/button/slider rows can all be freely mixed in the same
+# popup (see demo_menu below for an example combining all three). Either
+# trigger works with any row -- every built-in popup below 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)
+#   slider -- a draggable 0-100% track; dragging doesn't close the popup (popup_set)
+#
+# 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_set decide what's in it,
+#   in any combination, regardless of which of `buttons`/`slider` you put
+#   here, so either word works.
+#
+# popup_item : module_name : "Label" : "command"   -- one button row.
+#   Repeat for more rows. The first popup_item/popup_info line for a module
+#   replaces its built-in default rows (if any, including a popup_set
+#   slider row); later lines append to it. Add popup_set again afterwards
+#   if you cleared a built-in slider this way and still want it back.
+#
+# popup_info : module_name : "shell command"   -- one plain text row. Its
+#   label is this command's output, re-run fresh every time the popup
+#   opens (e.g. current IP address). Not clickable at all -- clicking it
+#   does nothing and the popup stays open.
+#
+# popup_set : module_name : "command"   -- adds (or updates) one slider row
+#   for this module. Run when the value changes; receives the new value as
+#   $1, e.g. "45%" (same convention as prefix_cmd's $1). The slider's
+#   starting position comes from the module's own command output, so no
+#   separate "get" command is needed. Can coexist with popup_item/popup_info
+#   rows on the same module.
+#
+# brightness, volume, cpu, bluetooth, usermenu and network below already
+# come with sensible defaults built in (see README) -- these directives are
+# for overriding them or adding this behaviour to your own custom modules.
+# Want click instead of hover for one of them? Just override its trigger,
+# e.g. `popup : usermenu : click : buttons`.
+#
+# Examples (already the defaults for these -- shown for reference):
+# popup      : cpu : hover : buttons
+# popup_info : cpu : "{ grep -m1 '^cpu ' /proc/stat; sleep 0.2; grep -m1 '^cpu ' /proc/stat; } | LC_ALL=C awk 'NR==1{for(i=2;i<=8;i++)t1+=$i; d1=$5+$6} NR==2{for(i=2;i<=8;i++)t2+=$i; d2=$5+$6; d=t2-t1; printf \"CPU: %d%%\n\", (d>0 ? (1-(d2-d1)/d)*100 : 0)}'"
+# popup_info : cpu : "LC_ALL=C free -h | awk '/^Mem:/{print \"Mem: \" $3\"/\"$2}'"
+#
+# popup     : brightness : hover : slider
+# popup_set : brightness : "brightnessctl set"
+#
+# popup     : volume : hover : slider
+# popup_set : volume : "wpctl set-volume @DEFAULT_AUDIO_SINK@"
+#
+# popup      : bluetooth : hover : buttons
+# popup_item : bluetooth : "Turn on"              : "bluetoothctl power on"
+# popup_item : bluetooth : "Turn off"             : "bluetoothctl power off"
+# popup_item : bluetooth : "Search for devices"   : "bluetoothctl --timeout 10 scan on"
+#
+# popup      : usermenu : hover : buttons
+# popup_item : usermenu : "Sleep"     : "systemctl suspend"
+# popup_item : usermenu : "Log out"  : "pkill sxwm"        # adjust to your WM/session
+# popup_item : usermenu : "Shut down" : "systemctl poweroff"
+#
+# popup      : network : hover : buttons
+# popup_info : network : "i=$(for d in /sys/class/net/*/wireless; do [ -d \"$d\" ] && basename \"$(dirname \"$d\")\" && break; done); if [ -z \"$i\" ]; then echo 'WiFi: none'; else ip=$(ip -4 -o addr show \"$i\" 2>/dev/null | awk '{print $4}' | cut -d/ -f1); [ -n \"$ip\" ] && echo \"WiFi ($i): $ip\" || echo \"WiFi ($i): disconnected\"; fi"
+# popup_info : network : "i=$(for d in /sys/class/net/*; do n=$(basename \"$d\"); case \"$n\" in lo|docker*|veth*|br-*|virbr*|tun*|tap*) continue;; esac; [ -d \"$d/wireless\" ] && continue; [ \"$(cat \"$d/type\" 2>/dev/null)\" = \"1\" ] && echo \"$n\" && break; done); if [ -z \"$i\" ]; then echo 'Ethernet: none'; else ip=$(ip -4 -o addr show \"$i\" 2>/dev/null | awk '{print $4}' | cut -d/ -f1); [ -n \"$ip\" ] && echo \"Ethernet ($i): $ip\" || echo \"Ethernet ($i): disconnected\"; fi"
+
+# Try-it-yourself demo popup -- a throwaway custom module combining all
+# three row kinds in one popup, so you can test hover/click/drag mechanics
+# safely before pointing popup_item/popup_info/popup_set at anything real:
+# a plain text row (not clickable at all), two buttons, and a slider. They
+# just fire a desktop notification via scripts/demo_popup.sh (installed to
+# $(PREFIX)/share/sxbar/scripts/ -- copy it to ~/.config/sxbar/scripts/,
+# same as battery_icon.sh/volume_icon.sh, then edit freely).
+#
+# custom     : demo_menu : "echo 50%" : 999
+# popup      : demo_menu : hover : buttons
+# popup_info : demo_menu : "echo 'Just text -- click me, nothing happens'"
+# popup_item : demo_menu : "Say hello" : "~/.config/sxbar/scripts/demo_popup.sh 'Hello!'"
+# popup_item : demo_menu : "Say bye"   : "~/.config/sxbar/scripts/demo_popup.sh 'Bye!'"
+# popup_set  : demo_menu : "~/.config/sxbar/scripts/demo_popup.sh"
+
 # Media controller example (requires playerctl)
 # playerctl uses the MPRIS2 protocol and works with most players automatically:
 # Firefox (YouTube, Spotify Web), Spotify, VLC, mpv, rhythmbox, etc.
diff --git a/scripts/demo_popup.sh b/scripts/demo_popup.sh
new file mode 100755
index 0000000..3ab393e
--- /dev/null
+++ b/scripts/demo_popup.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# sxbar demo popup action -- fires a desktop notification so you can see a
+# popup_item/popup_set command actually run, without wiring up anything
+# real yet. Point popup_item/popup_set at this script while you're testing
+# hover/click/drag, then swap the notify-send line below for your own
+# command once you're happy with how the popup behaves.
+#
+# Usage: demo_popup.sh "<message>"
+#   - popup_item calls it with a fixed label, e.g. "Say hello"
+#   - popup_set (slider) calls it with the new value as $1, e.g. "45%"
+
+msg="${1:-(no argument)}"
+notify-send "sxbar demo" "$msg"
diff --git a/src/defs.h b/src/defs.h
index 9f44fa1..fdfd6e8 100644
--- a/src/defs.h
+++ b/src/defs.h
@@ -16,6 +16,37 @@
 #define ALIGN_LEFT   1
 #define ALIGN_CENTER 2

+/* whether a module has a popup at all: NONE is 0 so existing configs
+ * (which never set popup) keep today's plain click/scroll behaviour with
+ * no floating window. A popup is a list of rows (PopupItem), each with
+ * its own POPUP_ROW_* type -- text, button and slider rows can all be
+ * mixed freely in the same popup. */
+#define POPUP_NONE    0
+#define POPUP_BUTTONS 1
+
+#define POPUP_TRIGGER_CLICK 0
+#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_SLIDER 2 /* draggable 0-100% track; doesn't close on click/drag */
+
+/* one row of a popup. label_command, if set, is re-run fresh (via
+ * run_command()) every time the popup opens instead of using a fixed
+ * label -- used by TEXT and BUTTON rows for live status text (label
+ * itself is then overwritten in place and freed/reused across opens). A
+ * SLIDER row instead seeds its value from the module's own command
+ * output, and set_command receives the new value as $1 when dragged. */
+typedef struct PopupItem {
+	int type;
+	char *label;
+	char *command;
+	char *label_command;
+	char *set_command;
+	int value;         /* SLIDER: current 0-100 */
+	int last_spawned;  /* SLIDER: last value set_command was actually run for */
+} PopupItem;
+
 typedef struct Module {
 	char *name;
 	char *command;
@@ -29,12 +60,21 @@ typedef struct Module {
 	int min_width;
 	int on_secondary;
 	int align;
+	int icon_only; /* show only the prefix/icon, never the module's own text */
 	XftColor xft_colour;
 	int has_colour;
 	int enabled;
 	int refresh_interval;
 	time_t last_update;
 	char *cached_output;
+	/* floating popup window opened on hover or click */
+	int popup_type;
+	int popup_trigger;
+	PopupItem *popup_items;
+	int popup_item_count;
+	int popup_item_max;
+	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 */
 } Module;

 typedef struct Config {
@@ -68,4 +108,18 @@ typedef struct Bar {
 	XftDraw *xft_draw;
 } Bar;

+/* the single floating popup window (at most one open at a time) */
+typedef struct Popup {
+	int open;
+	Window win;
+	Pixmap buffer;
+	XftDraw *xft_draw;
+	Module *module;
+	int bar_idx;
+	int trigger;
+	int x, y, w, h;
+	int hover_row;    /* row index highlighted (BUTTON rows only), or -1 */
+	int dragging_row; /* row index of the SLIDER row being dragged, or -1 */
+} Popup;
+
 typedef void (*EventHandler)(XEvent *);
diff --git a/src/parser.c b/src/parser.c
index 14fe50e..b0b76a3 100644
--- a/src/parser.c
+++ b/src/parser.c
@@ -121,6 +121,38 @@ static int grow_modules(Config *cfg)
 	return 0;
 }

+/* the first popup_item/popup_info line for a module wipes its built-in
+ * default rows (if any, including a popup_set-managed slider row); later
+ * lines from either directive just append. A popup_set line afterwards
+ * re-creates the slider row if you still want one alongside your rows. */
+static void clear_builtin_popup_items(Module *m)
+{
+	if (m->popup_items_from_config)
+		return;
+	for (int i = 0; i < m->popup_item_count; i++) {
+		free(m->popup_items[i].label);
+		free(m->popup_items[i].command);
+		free(m->popup_items[i].label_command);
+		free(m->popup_items[i].set_command);
+	}
+	m->popup_item_count = 0;
+	m->slider_item_idx = -1;
+	m->popup_items_from_config = 1;
+}
+
+static int grow_popup_items(Module *m)
+{
+	if (m->popup_item_count < m->popup_item_max)
+		return 0;
+	int newmax = m->popup_item_max ? m->popup_item_max * 2 : 4;
+	PopupItem *tmp = realloc(m->popup_items, newmax * sizeof *tmp);
+	if (!tmp)
+		return -1;
+	m->popup_items = tmp;
+	m->popup_item_max = newmax;
+	return 0;
+}
+
 int parse_config(Config *cfg)
 {
 	char path[PATH_MAX];
@@ -267,6 +299,7 @@ int parse_config(Config *cfg)
 			m->refresh_interval = interval;
 			m->last_update    = 0;
 			m->cached_output  = NULL;
+			m->slider_item_idx = -1;
 		} else if (!strcmp(key, "colour") || !strcmp(key, "color")) {
 			/* colour : module_name : #hex */
 			char *p1 = strchr(rest, ':');
@@ -356,6 +389,228 @@ int parse_config(Config *cfg)
 				fprintf(stderr, "sxbarc:%d: align: value must be 'left', 'center' or 'right', got '%s'\n",
 				        lineno, val);
 			}
+		} else if (!strcmp(key, "icon_only")) {
+			/* icon_only : module_name : true|false -- show only the prefix/icon,
+			 * never the module's own text (default: false) */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: icon_only 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: icon_only: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			m->icon_only = parse_bool(val);
+		} else if (!strcmp(key, "popup")) {
+			/* popup : module_name : hover|click : buttons|slider */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: popup missing fields\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name  = strip(rest);
+			char *rest2 = strip(p1 + 1);
+			char *p2    = strchr(rest2, ':');
+			if (!p2) {
+				fprintf(stderr, "sxbarc:%d: popup missing trigger/type\n", lineno);
+				continue;
+			}
+			*p2 = '\0';
+			char *trig_s = strip(rest2);
+			char *type_s = strip(p2 + 1);
+			strip_comment(type_s);
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: popup: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			if (!strcmp(trig_s, "hover")) {
+				m->popup_trigger = POPUP_TRIGGER_HOVER;
+			} else if (!strcmp(trig_s, "click")) {
+				m->popup_trigger = POPUP_TRIGGER_CLICK;
+			} else {
+				fprintf(stderr, "sxbarc:%d: popup: trigger must be 'hover' or 'click', got '%s'\n",
+				        lineno, trig_s);
+				continue;
+			}
+			/* popups are just a list of rows now (text/button/slider can be
+			 * mixed freely via popup_item/popup_info/popup_set) -- this
+			 * field only still exists so older configs keep parsing; both
+			 * values just mean "this module has a popup" */
+			if (!strcmp(type_s, "buttons") || !strcmp(type_s, "slider")) {
+				m->popup_type = POPUP_BUTTONS;
+			} else {
+				fprintf(stderr, "sxbarc:%d: popup: type must be 'buttons' or 'slider', got '%s'\n",
+				        lineno, type_s);
+				continue;
+			}
+		} else if (!strcmp(key, "popup_item")) {
+			/* popup_item : module_name : "Label" : "command" -- one row of a
+			 * POPUP_BUTTONS popup. The first popup_item line for a module
+			 * replaces its built-in default rows; later lines append. */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: popup_item missing fields\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name  = strip(rest);
+			char *after = strip(p1 + 1);
+
+			if (*after != '"' && *after != '\'') {
+				fprintf(stderr, "sxbarc:%d: popup_item label must be quoted\n", lineno);
+				continue;
+			}
+			char q = *after;
+			char *label_start = after + 1;
+			char *closing = strchr(label_start, q);
+			if (!closing) {
+				fprintf(stderr, "sxbarc:%d: popup_item label missing closing quote\n", lineno);
+				continue;
+			}
+			*closing = '\0';
+
+			char *tail = strip(closing + 1);
+			if (*tail != ':') {
+				fprintf(stderr, "sxbarc:%d: popup_item missing command\n", lineno);
+				continue;
+			}
+			tail = strip(tail + 1);
+			if (*tail != '"' && *tail != '\'') {
+				fprintf(stderr, "sxbarc:%d: popup_item command must be quoted\n", lineno);
+				continue;
+			}
+			q = *tail;
+			char *cmd_start = tail + 1;
+			closing = strchr(cmd_start, q);
+			if (!closing) {
+				fprintf(stderr, "sxbarc:%d: popup_item command missing closing quote\n", lineno);
+				continue;
+			}
+			*closing = '\0';
+
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: popup_item: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			clear_builtin_popup_items(m);
+			if (grow_popup_items(m) < 0) {
+				fprintf(stderr, "sxbarc: out of memory\n");
+				fclose(f);
+				return -1;
+			}
+			m->popup_items[m->popup_item_count].type          = POPUP_ROW_BUTTON;
+			m->popup_items[m->popup_item_count].label         = strdup(label_start);
+			m->popup_items[m->popup_item_count].command       = expand_home(cmd_start);
+			m->popup_items[m->popup_item_count].label_command = NULL;
+			m->popup_items[m->popup_item_count].set_command   = NULL;
+			m->popup_item_count++;
+		} else if (!strcmp(key, "popup_info")) {
+			/* popup_info : module_name : "shell command" -- one purely
+			 * informational row: its label is this command's output,
+			 * re-run fresh every time the popup opens. Not clickable at
+			 * all -- clicking it does nothing, the popup stays open. */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: popup_info missing name and command\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name  = strip(rest);
+			char *after = strip(p1 + 1);
+
+			if (*after != '"' && *after != '\'') {
+				fprintf(stderr, "sxbarc:%d: popup_info command must be quoted\n", lineno);
+				continue;
+			}
+			char q = *after;
+			char *cmd_start = after + 1;
+			char *closing = strchr(cmd_start, q);
+			if (!closing) {
+				fprintf(stderr, "sxbarc:%d: popup_info command missing closing quote\n", lineno);
+				continue;
+			}
+			*closing = '\0';
+
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: popup_info: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			clear_builtin_popup_items(m);
+			if (grow_popup_items(m) < 0) {
+				fprintf(stderr, "sxbarc: out of memory\n");
+				fclose(f);
+				return -1;
+			}
+			m->popup_items[m->popup_item_count].type          = POPUP_ROW_TEXT;
+			m->popup_items[m->popup_item_count].label         = NULL;
+			m->popup_items[m->popup_item_count].command       = NULL;
+			m->popup_items[m->popup_item_count].label_command = expand_home(cmd_start);
+			m->popup_items[m->popup_item_count].set_command   = NULL;
+			m->popup_item_count++;
+		} else if (!strcmp(key, "popup_set")) {
+			/* popup_set : module_name : "command" -- adds (or updates) one
+			 * slider row for this module; receives the new value (0-100)
+			 * as $1, same convention as prefix_cmd. Can coexist with
+			 * popup_item/popup_info rows on the same module -- text,
+			 * button and slider rows all mix freely in one popup. */
+			char *p1 = strchr(rest, ':');
+			if (!p1) {
+				fprintf(stderr, "sxbarc:%d: popup_set missing name and command\n", lineno);
+				continue;
+			}
+			*p1 = '\0';
+			char *name  = strip(rest);
+			char *after = strip(p1 + 1);
+
+			if (*after != '"' && *after != '\'') {
+				fprintf(stderr, "sxbarc:%d: popup_set command must be quoted\n", lineno);
+				continue;
+			}
+			char q = *after;
+			char *cmd_start = after + 1;
+			char *closing = strchr(cmd_start, q);
+			if (!closing) {
+				fprintf(stderr, "sxbarc:%d: popup_set command missing closing quote\n", lineno);
+				continue;
+			}
+			*closing = '\0';
+
+			Module *m = find_module(cfg, name);
+			if (!m) {
+				fprintf(stderr, "sxbarc:%d: popup_set: unknown module '%s'\n", lineno, name);
+				continue;
+			}
+			if (m->slider_item_idx >= 0) {
+				free(m->popup_items[m->slider_item_idx].set_command);
+				m->popup_items[m->slider_item_idx].set_command = expand_home(cmd_start);
+			} else {
+				if (grow_popup_items(m) < 0) {
+					fprintf(stderr, "sxbarc: out of memory\n");
+					fclose(f);
+					return -1;
+				}
+				PopupItem *it = &m->popup_items[m->popup_item_count];
+				it->type          = POPUP_ROW_SLIDER;
+				it->label         = NULL;
+				it->command       = NULL;
+				it->label_command = NULL;
+				it->set_command   = expand_home(cmd_start);
+				it->value         = 0;
+				it->last_spawned  = 0;
+				m->slider_item_idx = m->popup_item_count;
+				m->popup_item_count++;
+				m->popup_type = POPUP_BUTTONS; /* "has a popup" */
+			}
 		} 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" */
diff --git a/src/sxbar.c b/src/sxbar.c
index 62e21c6..69b2fe4 100644
--- a/src/sxbar.c
+++ b/src/sxbar.c
@@ -1,4 +1,5 @@
 #define _POSIX_C_SOURCE 200809L
+#include <ctype.h>
 #include <err.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -25,8 +26,11 @@ int find_bar(Window win);
 int get_current_workspace(void);
 char **get_workspace_name(int *count);
 void hdl_button(XEvent *xev);
+void hdl_button_release(XEvent *xev);
+void hdl_crossing(XEvent *xev);
 void hdl_dummy(XEvent *xev);
 void hdl_expose(XEvent *xev);
+void hdl_motion(XEvent *xev);
 void hdl_property(XEvent *xev);
 void init_defaults(void);
 void init_modules(void);
@@ -35,6 +39,7 @@ void run(void);
 char *run_command(const char *cmd);
 void setup(void);
 void update_modules(void);
+static void popup_close(void);

 EventHandler evtable[LASTEvent];
 XftFont  *font;
@@ -49,6 +54,7 @@ Bar *bars = NULL;
 int nbars = 0;
 int nmonitors = 0;
 int scr;
+static Popup popup;

 int window_on_monitor(Window win, int monitor_index) {
     XWindowAttributes attr;
@@ -97,6 +103,8 @@ static int text_width(const char *str)
 static const char *module_text(Module *m, char *buf, size_t bufsz)
 {
 	const char *pre = m->prefix_command ? m->prefix_cached : m->prefix;
+	if (m->icon_only)
+		return (pre && *pre) ? pre : "";
 	if (pre && *pre) {
 		snprintf(buf, bufsz, "%s%s", pre, m->cached_output);
 		return buf;
@@ -156,6 +164,13 @@ void cleanup_modules(void)
 		free(config.modules[i].prefix);
 		free(config.modules[i].prefix_command);
 		free(config.modules[i].prefix_cached);
+		for (int j = 0; j < config.modules[i].popup_item_count; j++) {
+			free(config.modules[i].popup_items[j].label);
+			free(config.modules[i].popup_items[j].command);
+			free(config.modules[i].popup_items[j].label_command);
+			free(config.modules[i].popup_items[j].set_command);
+		}
+		free(config.modules[i].popup_items);
 		if (config.modules[i].has_colour)
 			XftColorFree(dpy, vis, cmap, &config.modules[i].xft_colour);
 		free(config.modules[i].cached_output);
@@ -229,7 +244,8 @@ void create_bars(void)

 			XSetWindowAttributes wa = {.background_pixel = config.background_colour,
 			                           .border_pixel = config.border_colour,
-			                           .event_mask = ExposureMask | ButtonPressMask};
+			                           .event_mask = ExposureMask | ButtonPressMask |
+			                                         PointerMotionMask | LeaveWindowMask};

 			Window win = XCreateWindow(dpy, root, x, y, w, h, bw, CopyFromParent, InputOutput,
 			                        DefaultVisual(dpy, scr),
@@ -538,16 +554,15 @@ static void spawn(const char *cmd)
 		waitpid(pid, NULL, 0);
 }

-void hdl_button(XEvent *xev)
+/* find the module (if any) whose slot contains x_click on the given bar,
+ * mirroring draw_bar_into()'s three-group layout exactly. Returns NULL if
+ * x_click doesn't land on any enabled module's reserved slot. out_mx (if
+ * non-NULL) receives the slot's left edge, in the bar window's own
+ * coordinates. */
+static Module *module_at_x(int bar_idx, int x_click, int *out_mx)
 {
-	unsigned int btn = xev->xbutton.button;
-	if (btn != Button1 && btn != Button4 && btn != Button5)
-		return;
-
-	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[bar->monitor].width - 2 * config.horizontal_padding;
 	const int pad = 5, ws_sp = 10, mod_sp = 20;

@@ -565,7 +580,6 @@ void hdl_button(XEvent *xev)
 	}
 	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;
@@ -581,17 +595,410 @@ void hdl_button(XEvent *xev)
 			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;
+				if (out_mx) *out_mx = mx;
+				return m;
 			}
 			mx += slot + mod_sp;
 		}
 	}
+	return NULL;
+}
+
+/* root-space geometry of a bar window, replicating create_bars()'s formula.
+ * Kept in sync by hand -- there is no shared helper with create_bars(). */
+static void bar_root_geometry(int bar_idx, int *ox, int *oy, int *ow, int *oh)
+{
+	Bar *bar = &bars[bar_idx];
+	int i = bar->monitor;
+	int is_secondary = bar->is_secondary;
+	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;
+	*ox = x; *oy = y; *ow = w + 2 * bw; *oh = h + 2 * bw;
+}
+
+#define POPUP_PAD      8
+#define POPUP_ROW_PAD  6
+#define POPUP_MIN_W    140
+#define POPUP_SLIDER_W 170
+#define POPUP_TRACK_H  10
+#define POPUP_GAP      4
+
+static int popup_row_height(void)
+{
+	return font->ascent + font->descent + 2 * POPUP_ROW_PAD;
+}
+
+static int popup_item_height(PopupItem *it)
+{
+	return it->type == POPUP_ROW_SLIDER
+	           ? popup_row_height() + POPUP_ROW_PAD + POPUP_TRACK_H + POPUP_ROW_PAD
+	           : popup_row_height();
+}
+
+/* total popup content height across all of a module's rows (text, button
+ * and slider rows can all be mixed, each with its own height) */
+static int popup_total_height(Module *m)
+{
+	int h = 0;
+	for (int i = 0; i < m->popup_item_count; i++)
+		h += popup_item_height(&m->popup_items[i]);
+	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 y = 0;
+	for (int i = 0; i < idx; i++)
+		y += popup_item_height(&m->popup_items[i]);
+	return y;
+}
+
+/* which row (if any) contains popup-window-relative y, or -1 */
+static int popup_row_at_y(Module *m, int y)
+{
+	int cy = 0;
+	for (int i = 0; i < m->popup_item_count; i++) {
+		int rh = popup_item_height(&m->popup_items[i]);
+		if (y >= cy && y < cy + rh)
+			return i;
+		cy += rh;
+	}
+	return -1;
+}
+
+/* leading run of digits in a module's cached output (e.g. "45%" -> 45),
+ * used to seed a slider row with the module's current reading */
+static int extract_pct(const char *s)
+{
+	if (!s)
+		return 0;
+	while (*s && !isdigit((unsigned char)*s))
+		s++;
+	int v = atoi(s);
+	if (v < 0) v = 0;
+	if (v > 100) v = 100;
+	return v;
+}
+
+static void popup_close(void)
+{
+	if (!popup.open)
+		return;
+	XUngrabPointer(dpy, CurrentTime);
+	XftDrawDestroy(popup.xft_draw);
+	XFreePixmap(dpy, popup.buffer);
+	XDestroyWindow(dpy, popup.win);
+	memset(&popup, 0, sizeof popup);
+}
+
+static void popup_draw(void)
+{
+	Module *m = popup.module;
+
+	XSetForeground(dpy, gc, config.background_colour);
+	XFillRectangle(dpy, popup.buffer, gc, 0, 0, popup.w, popup.h);
+	if (config.border) {
+		XSetForeground(dpy, gc, config.border_colour);
+		XDrawRectangle(dpy, popup.buffer, gc, 0, 0, popup.w - 1, popup.h - 1);
+	}
+
+	for (int i = 0; i < m->popup_item_count; i++) {
+		PopupItem *it = &m->popup_items[i];
+		int ry = popup_row_y(m, i);
+		int text_y = ry + (popup_row_height() + font->ascent - font->descent) / 2;
+
+		if (it->type == POPUP_ROW_SLIDER) {
+			char label[64];
+			snprintf(label, sizeof label, "%s: %d%%", m->name, it->value);
+			XftDrawStringUtf8(popup.xft_draw, &xft_fg, font, POPUP_PAD, text_y,
+			                  (const FcChar8 *)label, strlen(label));
+
+			int track_x = POPUP_PAD;
+			int track_y = ry + popup_row_height() + POPUP_ROW_PAD;
+			int track_w = popup.w - 2 * POPUP_PAD;
+			XSetForeground(dpy, gc, config.foreground_colour);
+			XDrawRectangle(dpy, popup.buffer, gc, track_x, track_y, track_w, POPUP_TRACK_H);
+			int fill_w = track_w * it->value / 100;
+			if (fill_w > 0)
+				XFillRectangle(dpy, popup.buffer, gc, track_x, track_y, fill_w, POPUP_TRACK_H);
+			continue;
+		}
+
+		const char *label = it->label ? it->label : "";
+		if (it->type == POPUP_ROW_BUTTON && i == popup.hover_row) {
+			int row_h = popup_item_height(it);
+			XSetForeground(dpy, gc, config.foreground_colour);
+			XFillRectangle(dpy, popup.buffer, gc, 0, ry, popup.w, row_h);
+			XftDrawStringUtf8(popup.xft_draw, &xft_bg, font, POPUP_PAD, text_y,
+			                  (const FcChar8 *)label, strlen(label));
+		} else {
+			XftDrawStringUtf8(popup.xft_draw, &xft_fg, font, POPUP_PAD, text_y,
+			                  (const FcChar8 *)label, strlen(label));
+		}
+	}
+
+	XCopyArea(dpy, popup.buffer, popup.win, gc, 0, 0, popup.w, popup.h, 0, 0);
+}
+
+/* recompute a slider row's value from a pointer x (popup-window-relative)
+ * and, if it changed, spawn its set_command with the new value (as "NN%") */
+static void popup_slider_set_from_x(int row, int x)
+{
+	PopupItem *it = &popup.module->popup_items[row];
+	int track_w = popup.w - 2 * POPUP_PAD;
+	int v = (x - POPUP_PAD) * 100 / (track_w > 0 ? track_w : 1);
+	if (v < 0) v = 0;
+	if (v > 100) v = 100;
+	it->value = v;
+
+	if (v != it->last_spawned && it->set_command) {
+		char pct[8];
+		snprintf(pct, sizeof pct, "%d%%", v);
+		size_t len = strlen(it->set_command) + strlen(pct) + 2;
+		char *full = malloc(len);
+		snprintf(full, len, "%s %s", it->set_command, pct);
+		spawn(full);
+		free(full);
+		it->last_spawned = v;
+	}
+	popup_draw();
+}
+
+static void popup_open(int bar_idx, Module *m, int anchor_x)
+{
+	if (popup.open) {
+		if (popup.module == m)
+			return;
+		popup_close();
+	}
+
+	int bx, by, bw, bh;
+	bar_root_geometry(bar_idx, &bx, &by, &bw, &bh);
+	int is_secondary = bars[bar_idx].is_secondary;
+	int bottom_bar = is_secondary ? !config.bottom_bar : config.bottom_bar;
+
+	int w = POPUP_MIN_W;
+	for (int i = 0; i < m->popup_item_count; i++) {
+		PopupItem *it = &m->popup_items[i];
+		if (it->type == POPUP_ROW_SLIDER) {
+			it->value = extract_pct(m->cached_output);
+			it->last_spawned = it->value;
+			if (POPUP_SLIDER_W > w) w = POPUP_SLIDER_W;
+			continue;
+		}
+		if (it->label_command) {
+			free(it->label);
+			it->label = run_command(it->label_command);
+		}
+		int tw = text_width(it->label ? it->label : "") + 2 * POPUP_PAD;
+		if (tw > w) w = tw;
+	}
+	int h = popup_total_height(m);
+
+	int mon = bars[bar_idx].monitor;
+	int x = bx + anchor_x;
+	int y = bottom_bar ? by - h - POPUP_GAP : by + bh + POPUP_GAP;
+	if (x + w > monitors[mon].x_org + monitors[mon].width)
+		x = monitors[mon].x_org + monitors[mon].width - w;
+	if (x < monitors[mon].x_org)
+		x = monitors[mon].x_org;
+
+	XSetWindowAttributes wa = {
+	    .override_redirect = True,
+	    .background_pixel = config.background_colour,
+	    .event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask |
+	                  PointerMotionMask | LeaveWindowMask,
+	};
+	Window win = XCreateWindow(dpy, root, x, y, w, h, 0, CopyFromParent, InputOutput,
+	                           DefaultVisual(dpy, scr),
+	                           CWOverrideRedirect | CWBackPixel | CWEventMask, &wa);
+
+	Atom A_WM_TYPE = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE", False);
+	Atom A_WM_TYPE_POPUP = XInternAtom(dpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
+	XChangeProperty(dpy, win, A_WM_TYPE, XA_ATOM, 32, PropModeReplace,
+	                (unsigned char *)&A_WM_TYPE_POPUP, 1);
+
+	Visual  *vis  = DefaultVisual(dpy, scr);
+	Colormap cmap = DefaultColormap(dpy, scr);
+	Pixmap buf = XCreatePixmap(dpy, win, w, h, DefaultDepth(dpy, scr));
+
+	popup.open         = 1;
+	popup.win          = win;
+	popup.buffer       = buf;
+	popup.xft_draw     = XftDrawCreate(dpy, buf, vis, cmap);
+	popup.module       = m;
+	popup.bar_idx      = bar_idx;
+	popup.trigger      = m->popup_trigger;
+	popup.x = x; popup.y = y; popup.w = w; popup.h = h;
+	popup.hover_row    = -1;
+	popup.dragging_row = -1;
+
+	XMapRaised(dpy, win);
+	/* owner_events=True: clicks that land on the bar or the popup itself
+	 * still behave normally; a click anywhere else on screen is reported
+	 * to us instead (as a click on `win`), letting hdl_button dismiss the
+	 * popup like a regular dropdown menu closes on an outside click */
+	XGrabPointer(dpy, win, True,
+	             ButtonPressMask | ButtonReleaseMask | PointerMotionMask,
+	             GrabModeAsync, GrabModeAsync, None, None, CurrentTime);
+
+	popup_draw();
+}
+
+static void popup_handle_button(XEvent *xev)
+{
+	int x = xev->xbutton.x, y = xev->xbutton.y;
+	if (x < 0 || y < 0 || x >= popup.w || y >= popup.h) {
+		popup_close();
+		return;
+	}
+
+	Module *m = popup.module;
+	int row = popup_row_at_y(m, y);
+	if (row < 0) {
+		popup_close();
+		return;
+	}
+
+	PopupItem *it = &m->popup_items[row];
+	switch (it->type) {
+	case POPUP_ROW_BUTTON:
+		if (it->command && *it->command)
+			spawn(it->command);
+		popup_close();
+		break;
+	case POPUP_ROW_SLIDER:
+		popup.dragging_row = row;
+		popup_slider_set_from_x(row, x);
+		break;
+	default:
+		/* POPUP_ROW_TEXT: purely informational, not clickable at all --
+		 * the popup stays open, nothing happens */
+		break;
+	}
+}
+
+void hdl_button(XEvent *xev)
+{
+	Window win = xev->xbutton.window;
+
+	if (popup.open && win == popup.win) {
+		popup_handle_button(xev);
+		return;
+	}
+	if (popup.open) {
+		/* a click anywhere else -- another bar module, empty bar space,
+		 * or (via the pointer grab above) some other window entirely --
+		 * just dismisses the popup, same as any ordinary dropdown menu */
+		popup_close();
+		return;
+	}
+
+	unsigned int btn = xev->xbutton.button;
+	if (btn != Button1 && btn != Button4 && btn != Button5)
+		return;
+
+	int bar_idx = find_bar(win);
+	if (bars[bar_idx].win != win)
+		return;
+
+	int mx;
+	Module *m = module_at_x(bar_idx, xev->xbutton.x, &mx);
+	if (!m)
+		return;
+
+	if (btn == Button1 && m->popup_type != POPUP_NONE && m->popup_trigger == POPUP_TRIGGER_CLICK) {
+		popup_open(bar_idx, m, mx);
+		return;
+	}
+
+	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);
+}
+
+void hdl_button_release(XEvent *xev)
+{
+	if (popup.open && xev->xbutton.window == popup.win)
+		popup.dragging_row = -1;
+}
+
+void hdl_motion(XEvent *xev)
+{
+	Window win = xev->xmotion.window;
+
+	if (popup.open && win == popup.win) {
+		int x = xev->xmotion.x, y = xev->xmotion.y;
+		Module *m = popup.module;
+
+		if (popup.dragging_row >= 0) {
+			int cx = x;
+			if (cx < 0) cx = 0;
+			if (cx >= popup.w) cx = popup.w - 1;
+			popup_slider_set_from_x(popup.dragging_row, cx);
+			return;
+		}
+
+		int row = (x >= 0 && y >= 0 && x < popup.w && y < popup.h) ? popup_row_at_y(m, y) : -1;
+		if (row >= 0 && m->popup_items[row].type != POPUP_ROW_BUTTON)
+			row = -1; /* only BUTTON rows get a hover highlight */
+		if (row != popup.hover_row) {
+			popup.hover_row = row;
+			popup_draw();
+		}
+		return;
+	}
+
+	int bar_idx = find_bar(win);
+	if (bars[bar_idx].win != win)
+		return;
+
+	int mx;
+	Module *m = module_at_x(bar_idx, xev->xmotion.x, &mx);
+	if (m && m->popup_type != POPUP_NONE && m->popup_trigger == POPUP_TRIGGER_HOVER) {
+		if (!popup.open || popup.module != m)
+			popup_open(bar_idx, m, mx);
+	} else if (popup.open && popup.trigger == POPUP_TRIGGER_HOVER && popup.bar_idx == bar_idx) {
+		popup_close();
+	}
+}
+
+void hdl_crossing(XEvent *xev)
+{
+	if (xev->xcrossing.mode != NotifyNormal)
+		return; /* ignore grab-related pseudo crossings during a slider drag */
+	if (!popup.open || popup.trigger != POPUP_TRIGGER_HOVER)
+		return;
+	Window win = xev->xcrossing.window;
+	if (win != popup.win && win != bars[popup.bar_idx].win)
+		return;
+
+	/* leaving the bar downward (or the popup upward) crosses into the
+	 * other one of this pair -- only close once the pointer has actually
+	 * left both, not just whichever window it happened to leave first */
+	Window root_ret, child_ret;
+	int root_x, root_y, win_x, win_y;
+	unsigned int mask;
+	if (XQueryPointer(dpy, root, &root_ret, &child_ret, &root_x, &root_y, &win_x, &win_y, &mask)) {
+		if (root_x >= popup.x && root_x < popup.x + popup.w &&
+		    root_y >= popup.y && root_y < popup.y + popup.h)
+			return;
+		int bx, by, bw, bh;
+		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;
+	}
+	popup_close();
 }

 void hdl_dummy(XEvent *xev)
@@ -643,6 +1050,63 @@ int find_bar(Window win)
 	return 0;
 }

+static void grow_popup_items_or_die(Module *m)
+{
+	if (m->popup_item_count < m->popup_item_max)
+		return;
+	int newmax = m->popup_item_max ? m->popup_item_max * 2 : 4;
+	m->popup_items = realloc(m->popup_items, newmax * sizeof *m->popup_items);
+	m->popup_item_max = newmax;
+}
+
+/* append a built-in default BUTTON row -- mirrors the parser's popup_item
+ * handling but for compile-time defaults */
+static void add_popup_item(Module *m, const char *label, const char *cmd)
+{
+	grow_popup_items_or_die(m);
+	PopupItem *it = &m->popup_items[m->popup_item_count];
+	it->type          = POPUP_ROW_BUTTON;
+	it->label         = strdup(label);
+	it->command       = strdup(cmd);
+	it->label_command = NULL;
+	it->set_command   = NULL;
+	m->popup_item_count++;
+}
+
+/* append a built-in default TEXT row: purely informational, its label is
+ * re-run fresh (via label_command) every time the popup opens, and it's
+ * not clickable at all -- clicking it does nothing, the popup stays open */
+static void add_popup_info_item(Module *m, const char *label_cmd)
+{
+	grow_popup_items_or_die(m);
+	PopupItem *it = &m->popup_items[m->popup_item_count];
+	it->type          = POPUP_ROW_TEXT;
+	it->label         = NULL;
+	it->command       = NULL;
+	it->label_command = strdup(label_cmd);
+	it->set_command   = NULL;
+	m->popup_item_count++;
+}
+
+/* append a built-in default SLIDER row; mirrors the parser's popup_set
+ * handling but for compile-time defaults (module has no pre-existing
+ * slider row yet, so this always appends rather than updating in place) */
+static void add_popup_slider_item(Module *m, const char *set_cmd)
+{
+	grow_popup_items_or_die(m);
+	PopupItem *it = &m->popup_items[m->popup_item_count];
+	it->type          = POPUP_ROW_SLIDER;
+	it->label         = NULL;
+	it->command       = NULL;
+	it->label_command = NULL;
+	it->set_command   = strdup(set_cmd);
+	it->value         = 0;
+	it->last_spawned  = 0;
+	m->slider_item_idx = m->popup_item_count;
+	m->popup_item_count++;
+	m->popup_type = POPUP_BUTTONS;
+}
+
 void init_modules(void)
 {
 	config.max_modules = 10;
@@ -655,14 +1119,16 @@ void init_modules(void)
 	                                                 .enabled = True,
 	                                                 .refresh_interval = 1,
 	                                                 .last_update = 0,
-	                                                 .cached_output = NULL};
+	                                                 .cached_output = NULL,
+	                                                 .slider_item_idx = -1};
 	/* date */
 	config.modules[config.module_count++] = (Module){.name = strdup("date"),
 	                                                 .command = "date '+%Y-%m-%d'",
 	                                                 .enabled = True,
 	                                                 .refresh_interval = 60,
 	                                                 .last_update = 0,
-	                                                 .cached_output = NULL};
+	                                                 .cached_output = NULL,
+	                                                 .slider_item_idx = -1};
 	/* battery */
 	config.modules[config.module_count++] =
 	    (Module){.name = strdup("battery"),
@@ -672,8 +1138,9 @@ void init_modules(void)
 	             .enabled = False,
 	             .refresh_interval = 30,
 	             .last_update = 0,
-	             .cached_output = NULL};
-	/* volume */
+	             .cached_output = NULL,
+	             .slider_item_idx = -1};
+	/* volume -- hovering reveals a slider; dragging it runs its set_command */
 	config.modules[config.module_count++] =
 	    (Module){.name = strdup("volume"),
 	             /* awk does the arithmetic itself; no bc/bash/xargs needed */
@@ -682,8 +1149,12 @@ void init_modules(void)
 	             .enabled = True,
 	             .refresh_interval = 5,
 	             .last_update = 0,
-	             .cached_output = NULL};
-	/* cpu */
+	             .cached_output = NULL,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	add_popup_slider_item(&config.modules[config.module_count - 1],
+	                       "wpctl set-volume @DEFAULT_AUDIO_SINK@");
+	/* cpu -- hovering reveals more system info (cpu + memory + per-core load) */
 	config.modules[config.module_count++] =
 		(Module){.name = strdup("cpu"),
 	             /* two /proc/stat samples -- top(1)'s output is localised and unparseable */
@@ -695,7 +1166,114 @@ void init_modules(void)
 	             .enabled = False,
 	             .refresh_interval = 3,
 	             .last_update = 0,
-	             .cached_output = NULL};
+	             .cached_output = NULL,
+	             .popup_type = POPUP_BUTTONS,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	{
+		Module *cpu = &config.modules[config.module_count - 1];
+		add_popup_info_item(cpu,
+		    "{ grep -m1 '^cpu ' /proc/stat; sleep 0.2; grep -m1 '^cpu ' /proc/stat; } | "
+		    "LC_ALL=C awk 'NR==1{for(i=2;i<=8;i++)t1+=$i; d1=$5+$6} "
+		    "NR==2{for(i=2;i<=8;i++)t2+=$i; d2=$5+$6; d=t2-t1; "
+		    "printf \"CPU: %d%%\\n\", (d>0 ? (1-(d2-d1)/d)*100 : 0)}'");
+		add_popup_info_item(cpu, "LC_ALL=C free -h | awk '/^Mem:/{print \"Mem: \" $3\"/\"$2}'");
+		/* per-core load: two /proc/stat samples paired up by position via
+		 * awk arrays (no process substitution -- /bin/sh may be dash) */
+		add_popup_info_item(cpu,
+		    "a=$(grep '^cpu[0-9]' /proc/stat); n=$(printf '%s\\n' \"$a\" | wc -l); "
+		    "sleep 0.2; b=$(grep '^cpu[0-9]' /proc/stat); "
+		    "printf '%s\\n%s\\n' \"$a\" \"$b\" | LC_ALL=C awk -v n=\"$n\" "
+		    "'NR<=n{t1[NR]=0; for(i=2;i<=8;i++)t1[NR]+=$i; d1[NR]=$5+$6; name[NR]=$1} "
+		    "NR>n{j=NR-n; t2=0; for(i=2;i<=8;i++)t2+=$i; d2=$5+$6; d=t2-t1[j]; "
+		    "pct=(d>0)?(1-(d2-d1[j])/d)*100:0; gsub(/cpu/,\"\",name[j]); "
+		    "printf \"%s:%d%% \", name[j], pct} "
+		    "BEGIN{printf \"Cores: \"} END{print \"\"}'");
+	}
+	/* brightness -- hovering reveals a slider; dragging it runs its set_command */
+	config.modules[config.module_count++] =
+	    (Module){.name = strdup("brightness"),
+	             .command = "brightnessctl -m 2>/dev/null | awk -F, '{print $4}'",
+	             .enabled = False,
+	             .refresh_interval = 5,
+	             .last_update = 0,
+	             .cached_output = NULL,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	add_popup_slider_item(&config.modules[config.module_count - 1], "brightnessctl set");
+	/* bluetooth -- hovering reveals a floating menu: power on/off, scan, pair */
+	config.modules[config.module_count++] =
+	    (Module){.name = strdup("bluetooth"),
+	             .command = "bluetoothctl show 2>/dev/null | grep -q 'Powered: yes' && "
+	                        "echo 'On' || echo 'Off'",
+	             .enabled = False,
+	             .refresh_interval = 10,
+	             .last_update = 0,
+	             .cached_output = NULL,
+	             .popup_type = POPUP_BUTTONS,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	{
+		Module *bt = &config.modules[config.module_count - 1];
+		add_popup_item(bt, "Turn on",  "bluetoothctl power on");
+		add_popup_item(bt, "Turn off", "bluetoothctl power off");
+		add_popup_item(bt, "Search for devices", "bluetoothctl --timeout 10 scan on");
+		add_popup_item(bt, "Pair last found device",
+		    "m=$(bluetoothctl devices | tail -n1 | awk '{print $2}'); "
+		    "[ -n \"$m\" ] && bluetoothctl pair \"$m\" && bluetoothctl trust \"$m\" "
+		    "&& bluetoothctl connect \"$m\"");
+	}
+	/* usermenu -- hovering reveals a floating menu: sleep, log out, shut down */
+	config.modules[config.module_count++] =
+	    (Module){.name = strdup("usermenu"),
+	             .command = "whoami",
+	             .enabled = True,
+	             .refresh_interval = 300,
+	             .last_update = 0,
+	             .cached_output = NULL,
+	             .popup_type = POPUP_BUTTONS,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	{
+		Module *um = &config.modules[config.module_count - 1];
+		add_popup_item(um, "Sleep",     "systemctl suspend");
+		/* sxwm (https://github.com/uint23/sxwm) has no session manager or
+		 * external IPC to trigger its own `quit` keybind, so logging out
+		 * means ending the X session by killing the WM -- adjust for your
+		 * own WM/session via popup_item in sxbarc if this doesn't fit */
+		add_popup_item(um, "Log out",   "pkill sxwm");
+		add_popup_item(um, "Shut down", "systemctl poweroff");
+	}
+	/* network -- hovering reveals a floating menu showing WiFi/Ethernet + IPs */
+	config.modules[config.module_count++] =
+	    (Module){.name = strdup("network"),
+	             .command = "ip route show default 2>/dev/null | grep -q . && echo Online || echo Offline",
+	             .enabled = False,
+	             .refresh_interval = 10,
+	             .last_update = 0,
+	             .cached_output = NULL,
+	             .popup_type = POPUP_BUTTONS,
+	             .popup_trigger = POPUP_TRIGGER_HOVER,
+	             .slider_item_idx = -1};
+	{
+		Module *net = &config.modules[config.module_count - 1];
+		/* wifi: first interface with a /sys/class/net/<if>/wireless dir */
+		add_popup_info_item(net,
+		    "i=$(for d in /sys/class/net/*/wireless; do [ -d \"$d\" ] && "
+		    "basename \"$(dirname \"$d\")\" && break; done); "
+		    "if [ -z \"$i\" ]; then echo 'WiFi: none'; else "
+		    "ip=$(ip -4 -o addr show \"$i\" 2>/dev/null | awk '{print $4}' | cut -d/ -f1); "
+		    "[ -n \"$ip\" ] && echo \"WiFi ($i): $ip\" || echo \"WiFi ($i): disconnected\"; fi");
+		/* ethernet: first non-virtual, non-wireless interface of ARPHRD_ETHER type */
+		add_popup_info_item(net,
+		    "i=$(for d in /sys/class/net/*; do n=$(basename \"$d\"); "
+		    "case \"$n\" in lo|docker*|veth*|br-*|virbr*|tun*|tap*) continue;; esac; "
+		    "[ -d \"$d/wireless\" ] && continue; "
+		    "[ \"$(cat \"$d/type\" 2>/dev/null)\" = \"1\" ] && echo \"$n\" && break; done); "
+		    "if [ -z \"$i\" ]; then echo 'Ethernet: none'; else "
+		    "ip=$(ip -4 -o addr show \"$i\" 2>/dev/null | awk '{print $4}' | cut -d/ -f1); "
+		    "[ -n \"$ip\" ] && echo \"Ethernet ($i): $ip\" || echo \"Ethernet ($i): disconnected\"; fi");
+	}
 }

 unsigned long parse_col(const char *hex)
@@ -824,6 +1402,9 @@ void setup(void)
 	}
 	evtable[Expose] = hdl_expose;
 	evtable[ButtonPress] = hdl_button;
+	evtable[ButtonRelease] = hdl_button_release;
+	evtable[MotionNotify] = hdl_motion;
+	evtable[LeaveNotify] = hdl_crossing;
 	evtable[PropertyNotify] = hdl_property;
 	XSelectInput(dpy, root, PropertyChangeMask);