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);