Last active
September 3, 2026 10:25
-
-
Save HalanoSiblee/7b205fa34add4cdefb8a4eb19cbf815f to your computer and use it in GitHub Desktop.
X11/xcb volume bar osd helper for window managers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| g++ -std=c++20 -O3 -march=native -flto \ | |
| -fomit-frame-pointer -fno-plt \ | |
| -ffunction-sections -fdata-sections \ | |
| -Wl,--gc-sections -Wl,--as-needed -s \ | |
| -DNDEBUG \ | |
| mothxcbvol.cpp -lxcb -o mothxcbvol | |
| */ | |
| /* | |
| MY i3 binds | |
| bindsym XF86AudioRaiseVolume exec --no-startup-id amixer set Master 10%+ && mothxcbvol | |
| bindsym XF86AudioLowerVolume exec --no-startup-id amixer set Master 10%- && mothxcbvol | |
| */ | |
| #include <xcb/xcb.h> | |
| #include <fcntl.h> | |
| #include <poll.h> | |
| #include <signal.h> | |
| #include <sys/file.h> | |
| #include <unistd.h> | |
| #include <algorithm> | |
| #include <array> | |
| #include <charconv> | |
| #include <chrono> | |
| #include <cstring> | |
| #include <memory> | |
| #include <string> | |
| #include <string_view> | |
| using namespace std::chrono_literals; | |
| using Clock = std::chrono::steady_clock; | |
| constexpr int BAR_HEIGHT = 28; | |
| constexpr int MARGIN_BOT = 80; | |
| constexpr int SNAP_RADIUS = 2; | |
| constexpr auto TIMEOUT_DUR = 3s; | |
| constexpr auto DEBOUNCE_DUR = 500ms; | |
| enum class Backend { Wpctl, Pactl, Amixer }; | |
| struct FileLock { | |
| int fd = -1; | |
| FileLock() { | |
| std::string path; | |
| if (const char* xdg = std::getenv("XDG_RUNTIME_DIR"); xdg && *xdg) | |
| path = std::string(xdg) + "/mothxcbvol.lock"; | |
| else | |
| path = "/tmp/mothxcbvol" + std::to_string(::getuid()) + ".lock"; | |
| fd = ::open(path.c_str(), O_CREAT | O_RDWR, 0600); | |
| if (fd >= 0) { | |
| ::fcntl(fd, F_SETFD, FD_CLOEXEC); | |
| if (::flock(fd, LOCK_EX | LOCK_NB) < 0) { | |
| ::close(fd); | |
| fd = -1; | |
| } | |
| } | |
| } | |
| ~FileLock() { if (fd >= 0) ::close(fd); } | |
| explicit operator bool() const noexcept { return fd >= 0; } | |
| }; | |
| struct XcbContext { | |
| xcb_connection_t* conn = nullptr; | |
| const xcb_setup_t* setup = nullptr; | |
| xcb_screen_t* screen = nullptr; | |
| xcb_get_keyboard_mapping_reply_t* km_reply = nullptr; | |
| xcb_font_t font = 0; | |
| xcb_window_t win = 0; | |
| XcbContext() { | |
| conn = xcb_connect(nullptr, nullptr); | |
| if (!xcb_connection_has_error(conn)) { | |
| setup = xcb_get_setup(conn); | |
| screen = xcb_setup_roots_iterator(setup).data; | |
| auto cookie = xcb_get_keyboard_mapping(conn, setup->min_keycode, | |
| setup->max_keycode - setup->min_keycode + 1); | |
| km_reply = xcb_get_keyboard_mapping_reply(conn, cookie, nullptr); | |
| } | |
| } | |
| ~XcbContext() { | |
| if (!conn) return; | |
| xcb_ungrab_keyboard(conn, XCB_CURRENT_TIME); | |
| if (font) xcb_close_font(conn, font); | |
| if (win) xcb_destroy_window(conn, win); | |
| if (km_reply) std::free(km_reply); | |
| xcb_disconnect(conn); | |
| } | |
| bool valid() const noexcept { return conn && !xcb_connection_has_error(conn); } | |
| }; | |
| static Backend detect_backend() noexcept { | |
| if (::system("command -v wpctl >/dev/null 2>&1 && wpctl get-volume @DEFAULT_AUDIO_SINK@ >/dev/null 2>&1") == 0) | |
| return Backend::Wpctl; | |
| if (::system("command -v pactl >/dev/null 2>&1 && pactl get-sink-volume @DEFAULT_SINK@ >/dev/null 2>&1") == 0) | |
| return Backend::Pactl; | |
| return Backend::Amixer; | |
| } | |
| static int get_volume(Backend b) { | |
| const char* cmd = nullptr; | |
| switch (b) { | |
| case Backend::Wpctl: cmd = "wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null | awk '{print int($2*100)}'"; break; | |
| case Backend::Pactl: cmd = "pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -o '[0-9]\\+%' | head -n1 | tr -d '%'"; break; | |
| case Backend::Amixer: cmd = "amixer get Master 2>/dev/null | grep -o '[0-9]\\+%' | head -n1 | tr -d '%'"; break; | |
| } | |
| std::unique_ptr<FILE, decltype(&::pclose)> pipe(::popen(cmd, "r"), &::pclose); | |
| int v = 50; | |
| if (pipe && std::fscanf(pipe.get(), "%d", &v) != 1) v = 50; | |
| return std::clamp(v, 0, 100); | |
| } | |
| static void set_volume_async(Backend b, int v) { | |
| if (pid_t pid = ::fork(); pid == 0) { | |
| ::close(0); ::close(1); ::close(2); | |
| char buf[32]; | |
| if (b == Backend::Wpctl) { | |
| std::snprintf(buf, sizeof(buf), "%.2f", v / 100.0f); | |
| ::execlp("wpctl", "wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", buf, nullptr); | |
| } else if (b == Backend::Pactl) { | |
| std::snprintf(buf, sizeof(buf), "%d%%", v); | |
| ::execlp("pactl", "pactl", "set-sink-volume", "@DEFAULT_SINK@", buf, nullptr); | |
| } else { | |
| std::snprintf(buf, sizeof(buf), "%d%%", v); | |
| ::execlp("amixer", "amixer", "set", "Master", buf, nullptr); | |
| } | |
| ::_exit(0); | |
| } | |
| } | |
| static uint32_t parse_color(std::string_view sv, uint32_t fallback) noexcept { | |
| if (sv.starts_with('#')) sv.remove_prefix(1); | |
| else if (sv.starts_with("0x") || sv.starts_with("0X")) sv.remove_prefix(2); | |
| uint32_t val = 0; | |
| auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), val, 16); | |
| return (ec == std::errc{}) ? val : fallback; | |
| } | |
| static constexpr int apply_magnet(int raw) noexcept { | |
| int nearest = ((raw + 5) / 10) * 10; | |
| return (std::abs(raw - nearest) <= SNAP_RADIUS) ? nearest : raw; | |
| } | |
| static void draw_text(xcb_connection_t* c, xcb_drawable_t d, xcb_gcontext_t gc, | |
| int16_t x, int16_t y, std::string_view str) { | |
| std::array<uint8_t, 258> buf; | |
| buf[0] = static_cast<uint8_t>(str.size()); | |
| buf[1] = 0; | |
| std::memcpy(buf.data() + 2, str.data(), str.size()); | |
| xcb_poly_text_8(c, d, gc, x, y, static_cast<uint32_t>(str.size() + 2), buf.data()); | |
| } | |
| static void draw(xcb_connection_t* c, xcb_window_t win, | |
| xcb_gcontext_t gc_bg, xcb_gcontext_t gc_fg, | |
| xcb_gcontext_t gc_txt_fill, xcb_gcontext_t gc_txt_empty, | |
| int w, int vol) { | |
| xcb_rectangle_t bg = {0, 0, static_cast<uint16_t>(w), BAR_HEIGHT}; | |
| xcb_poly_fill_rectangle(c, win, gc_bg, 1, &bg); | |
| int fill_w = (vol * w) / 100; | |
| if (fill_w > 0) { | |
| xcb_rectangle_t fg = {0, 0, static_cast<uint16_t>(fill_w), BAR_HEIGHT}; | |
| xcb_poly_fill_rectangle(c, win, gc_fg, 1, &fg); | |
| } | |
| xcb_rectangle_t clip_fill = { 0, 0, static_cast<uint16_t>(fill_w), BAR_HEIGHT }; | |
| xcb_rectangle_t clip_empty = { static_cast<int16_t>(fill_w), 0, static_cast<uint16_t>(w - fill_w), BAR_HEIGHT }; | |
| xcb_set_clip_rectangles(c, XCB_CLIP_ORDERING_UNSORTED, gc_txt_fill, 0, 0, 1, &clip_fill); | |
| xcb_set_clip_rectangles(c, XCB_CLIP_ORDERING_UNSORTED, gc_txt_empty, 0, 0, 1, &clip_empty); | |
| for (int i = 0; i <= 100; i += 10) { | |
| int tx = (i * w) / 100; | |
| if (i > 0 && i < 100) { | |
| int th = (i == 50) ? 7 : 4; | |
| xcb_segment_t seg = { static_cast<int16_t>(tx), 0, static_cast<int16_t>(tx), static_cast<int16_t>(th) }; | |
| xcb_poly_segment(c, win, gc_txt_fill, 1, &seg); | |
| xcb_poly_segment(c, win, gc_txt_empty, 1, &seg); | |
| } | |
| std::array<char, 8> num; | |
| auto [ptr, _] = std::to_chars(num.data(), num.data() + num.size(), i); | |
| std::string_view sv(num.data(), ptr - num.data()); | |
| int tw = static_cast<int>(sv.size()) * 6; | |
| int text_x = tx - tw / 2; | |
| text_x = std::clamp(text_x, 4, w - 4 - tw); | |
| draw_text(c, win, gc_txt_fill, text_x, 19, sv); | |
| draw_text(c, win, gc_txt_empty, text_x, 19, sv); | |
| } | |
| xcb_flush(c); | |
| } | |
| int main(int argc, char* argv[]) { | |
| FileLock lock; | |
| if (!lock) return 0; | |
| ::signal(SIGCHLD, SIG_IGN); | |
| std::string_view color_arg; | |
| for (int i = 1; i < argc; ++i) { | |
| if (std::string_view(argv[i]) == "-c" && i + 1 < argc) | |
| color_arg = argv[++i]; | |
| } | |
| if (color_arg.empty()) { | |
| if (const char* env = std::getenv("ACCENT_COLOR"); env) | |
| color_arg = env; | |
| } | |
| const uint32_t fg_col = parse_color(color_arg, 0x89b4fa); | |
| const Backend backend = detect_backend(); | |
| XcbContext ctx; | |
| if (!ctx.valid()) return 1; | |
| const int bar_width = ctx.screen->width_in_pixels / 4; | |
| const int x = (ctx.screen->width_in_pixels - bar_width) / 2; | |
| const int y = ctx.screen->height_in_pixels - BAR_HEIGHT - MARGIN_BOT; | |
| ctx.win = xcb_generate_id(ctx.conn); | |
| uint32_t mask = XCB_CW_BACK_PIXEL | XCB_CW_BORDER_PIXEL | XCB_CW_OVERRIDE_REDIRECT | XCB_CW_EVENT_MASK; | |
| uint32_t values[] = { | |
| 0x181825, | |
| fg_col, | |
| 1, | |
| XCB_EVENT_MASK_EXPOSURE | XCB_EVENT_MASK_BUTTON_PRESS | | |
| XCB_EVENT_MASK_BUTTON_RELEASE | XCB_EVENT_MASK_BUTTON_MOTION | | |
| XCB_EVENT_MASK_KEY_PRESS | |
| }; | |
| xcb_create_window(ctx.conn, XCB_COPY_FROM_PARENT, ctx.win, ctx.screen->root, | |
| x, y, bar_width, BAR_HEIGHT, 2, | |
| XCB_WINDOW_CLASS_INPUT_OUTPUT, | |
| ctx.screen->root_visual, mask, values); | |
| ctx.font = xcb_generate_id(ctx.conn); | |
| xcb_open_font(ctx.conn, ctx.font, 5, "fixed"); | |
| int r = (fg_col >> 16) & 0xff, g = (fg_col >> 8) & 0xff, b = fg_col & 0xff; | |
| int lum = (r * 299 + g * 587 + b * 114) / 1000; | |
| uint32_t txt_fill_col = (lum > 135) ? 0x11111b : 0xffffff; | |
| xcb_gcontext_t gc_bg = xcb_generate_id(ctx.conn); | |
| xcb_gcontext_t gc_fg = xcb_generate_id(ctx.conn); | |
| xcb_gcontext_t gc_txt_fill = xcb_generate_id(ctx.conn); | |
| xcb_gcontext_t gc_txt_empty = xcb_generate_id(ctx.conn); | |
| uint32_t bg_col = 0x181825; | |
| xcb_create_gc(ctx.conn, gc_bg, ctx.win, XCB_GC_FOREGROUND, &bg_col); | |
| xcb_create_gc(ctx.conn, gc_fg, ctx.win, XCB_GC_FOREGROUND, &fg_col); | |
| uint32_t txt_mask = XCB_GC_FOREGROUND | XCB_GC_FONT; | |
| uint32_t v_fill[] = { txt_fill_col, ctx.font }; | |
| uint32_t v_empt[] = { 0x6c7086, ctx.font }; | |
| xcb_create_gc(ctx.conn, gc_txt_fill, ctx.win, txt_mask, v_fill); | |
| xcb_create_gc(ctx.conn, gc_txt_empty, ctx.win, txt_mask, v_empt); | |
| xcb_map_window(ctx.conn, ctx.win); | |
| xcb_grab_keyboard(ctx.conn, 1, ctx.win, XCB_CURRENT_TIME, XCB_GRAB_MODE_ASYNC, XCB_GRAB_MODE_ASYNC); | |
| xcb_flush(ctx.conn); | |
| int vol = get_volume(backend); | |
| draw(ctx.conn, ctx.win, gc_bg, gc_fg, gc_txt_fill, gc_txt_empty, bar_width, vol); | |
| struct pollfd pfd = { .fd = xcb_get_file_descriptor(ctx.conn), .events = POLLIN, .revents = 0 }; | |
| auto last_interaction = Clock::now(); | |
| int pending_vol = -1; | |
| auto commit_time = Clock::time_point::min(); | |
| bool dragging = false; | |
| while (true) { | |
| auto now = Clock::now(); | |
| if (pending_vol != -1 && now >= commit_time) { | |
| set_volume_async(backend, pending_vol); | |
| pending_vol = -1; | |
| } | |
| auto time_left_exit = std::chrono::duration_cast<std::chrono::milliseconds>((last_interaction + TIMEOUT_DUR) - now); | |
| if (time_left_exit <= 0ms) break; | |
| auto poll_timeout = time_left_exit; | |
| if (pending_vol != -1) { | |
| auto time_left_commit = std::chrono::duration_cast<std::chrono::milliseconds>(commit_time - now); | |
| if (time_left_commit > 0ms && time_left_commit < poll_timeout) | |
| poll_timeout = time_left_commit; | |
| } | |
| if (::poll(&pfd, 1, static_cast<int>(poll_timeout.count())) < 0) break; | |
| xcb_generic_event_t* ev = nullptr; | |
| while ((ev = xcb_poll_for_event(ctx.conn))) { | |
| std::unique_ptr<xcb_generic_event_t, decltype(&std::free)> ev_owner(ev, &std::free); | |
| uint8_t type = ev->response_type & ~0x80; | |
| if (type == XCB_EXPOSE) { | |
| draw(ctx.conn, ctx.win, gc_bg, gc_fg, gc_txt_fill, gc_txt_empty, bar_width, vol); | |
| } else if (type == XCB_BUTTON_PRESS) { | |
| auto* bp = reinterpret_cast<xcb_button_press_event_t*>(ev); | |
| if (bp->detail == 1) { | |
| dragging = true; | |
| vol = apply_magnet(std::clamp((bp->event_x * 100) / bar_width, 0, 100)); | |
| pending_vol = vol; | |
| commit_time = Clock::now() + DEBOUNCE_DUR; | |
| last_interaction = Clock::now(); | |
| draw(ctx.conn, ctx.win, gc_bg, gc_fg, gc_txt_fill, gc_txt_empty, bar_width, vol); | |
| } | |
| } else if (type == XCB_BUTTON_RELEASE) { | |
| dragging = false; | |
| last_interaction = Clock::now(); | |
| } else if (type == XCB_MOTION_NOTIFY && dragging) { | |
| auto* mot = reinterpret_cast<xcb_motion_notify_event_t*>(ev); | |
| vol = apply_magnet(std::clamp((mot->event_x * 100) / bar_width, 0, 100)); | |
| pending_vol = vol; | |
| commit_time = Clock::now() + DEBOUNCE_DUR; | |
| last_interaction = Clock::now(); | |
| draw(ctx.conn, ctx.win, gc_bg, gc_fg, gc_txt_fill, gc_txt_empty, bar_width, vol); | |
| } else if (type == XCB_KEY_PRESS) { | |
| auto* kp = reinterpret_cast<xcb_key_press_event_t*>(ev); | |
| xcb_keysym_t sym = 0; | |
| if (ctx.km_reply && kp->detail >= ctx.setup->min_keycode) { | |
| int idx = (kp->detail - ctx.setup->min_keycode) * ctx.km_reply->keysyms_per_keycode; | |
| sym = (reinterpret_cast<xcb_keysym_t*>(ctx.km_reply + 1))[idx]; | |
| } | |
| bool changed = true; | |
| if (sym == 0xff51 || sym == 0xff54) vol = std::max(0, vol - 5); | |
| else if (sym == 0xff53 || sym == 0xff52) vol = std::min(100, vol + 5); | |
| else if (sym >= '1' && sym <= '9') vol = (sym - '0') * 10; | |
| else if (sym == '0') vol = 100; | |
| else if (sym >= 0xffb1 && sym <= 0xffb9) vol = (sym - 0xffb0) * 10; | |
| else if (sym == 0xffb0) vol = 100; | |
| else if (sym == 0xff1b) break; | |
| else changed = false; | |
| if (changed) { | |
| vol = apply_magnet(vol); | |
| pending_vol = vol; | |
| commit_time = Clock::now() + DEBOUNCE_DUR; | |
| last_interaction = Clock::now(); | |
| draw(ctx.conn, ctx.win, gc_bg, gc_fg, gc_txt_fill, gc_txt_empty, bar_width, vol); | |
| } | |
| } | |
| } | |
| } | |
| if (pending_vol != -1) | |
| set_volume_async(backend, pending_vol); | |
| return 0; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment