waybar-dynamic 0.1.0

Dynamic widget CFFI module for Waybar — add, update, and remove widgets at runtime via Unix socket.
Documentation
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use serde::Deserialize;
use waybar_cffi::{
    gtk::{
        glib,
        prelude::{ContainerExt, StyleContextExt, WidgetExt},
        Box as GtkBox, EventBox, Label, Orientation,
    },
    waybar_module, InitInfo, Module,
};
use waybar_dynamic_core::socket::socket_path;

mod ipc;
mod state;

use state::SharedState;

/// Global registry (Send+Sync): tracks which names already have a listener.
static LISTENER_REGISTRY: std::sync::LazyLock<Mutex<HashMap<String, SharedState>>> =
    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));

// GTK-thread-local: all GtkBox containers per instance name.
// Each init() adds its container. rebuild_ui updates all live ones.
thread_local! {
    static CONTAINERS: RefCell<HashMap<String, Vec<GtkBox>>> =
        RefCell::new(HashMap::new());
}

#[derive(Deserialize)]
struct Config {
    #[serde(default = "default_name")]
    name: String,
    #[serde(default = "default_spacing")]
    spacing: i32,
}

fn default_name() -> String {
    "waybar-dynamic".to_string()
}

fn default_spacing() -> i32 {
    4
}

struct DynamicModule {
    _state: SharedState,
}

impl Module for DynamicModule {
    type Config = Config;

    fn init(info: &InitInfo, config: Config) -> Self {
        let root = info.get_root_widget();
        let inner = GtkBox::new(Orientation::Horizontal, config.spacing);

        inner.set_widget_name("waybar-dynamic");
        inner.style_context().add_class(&config.name);

        root.add(&inner);
        root.show_all();

        // Register this container.
        CONTAINERS.with(|c| {
            c.borrow_mut()
                .entry(config.name.clone())
                .or_default()
                .push(inner);
        });

        let mut registry = LISTENER_REGISTRY.lock().expect("waybar-dynamic: poisoned mutex");

        if let Some(state) = registry.get(&config.name).cloned() {
            // Listener already exists — rebuild all containers with current state.
            eprintln!(
                "waybar-dynamic: reusing listener for '{}'",
                config.name
            );
            rebuild_all(&config.name, &state);
            drop(registry);
            return DynamicModule { _state: state };
        }

        // First init — create state, listener, rebuild task.
        let state = SharedState::new();

        let (tx, rx) = async_channel::unbounded::<()>();

        let rebuild_name = config.name.clone();
        let rebuild_state = state.clone();
        glib::MainContext::default().spawn_local(async move {
            while rx.recv().await.is_ok() {
                rebuild_all(&rebuild_name, &rebuild_state);
            }
        });

        let on_update: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || {
            let _ = tx.send_blocking(());
        });

        ipc::spawn_listener(socket_path(&config.name), state.clone(), on_update);

        registry.insert(config.name, state.clone());
        drop(registry);

        DynamicModule { _state: state }
    }
}

/// Rebuild widgets in all containers for a given instance name.
/// Skips containers that are no longer realized (orphaned by waybar).
fn rebuild_all(name: &str, state: &SharedState) {
    CONTAINERS.with(|c| {
        let mut map = c.borrow_mut();
        if let Some(containers) = map.get_mut(name) {
            // Prune dead containers.
            containers.retain(|b| b.is_realized());
            for inner in containers.iter() {
                rebuild_ui(inner, state);
            }
        }
    });
}

fn rebuild_ui(inner: &GtkBox, state: &SharedState) {
    let snapshot = state.snapshot();
    for child in inner.children() {
        inner.remove(&child);
    }

    for spec in snapshot {
        let label = Label::new(Some(&spec.label));

        if let Some(id) = &spec.id {
            label.set_widget_name(id.as_str());
        }

        let ctx = label.style_context();
        for class in &spec.classes {
            ctx.add_class(class.as_str());
        }

        if let Some(tip) = &spec.tooltip {
            label.set_tooltip_text(Some(tip.as_str()));
        }

        let event_box = EventBox::new();
        event_box.add(&label);

        let hover_label = label.clone();
        event_box.connect_enter_notify_event(move |_, _| {
            hover_label.style_context().add_class("hover");
            glib::Propagation::Proceed
        });

        let hover_label = label.clone();
        event_box.connect_leave_notify_event(move |_, _| {
            hover_label.style_context().remove_class("hover");
            glib::Propagation::Proceed
        });

        if spec.on_click.is_some() || spec.on_right_click.is_some() || spec.on_middle_click.is_some() {
            let left_cmd = spec.on_click.clone();
            let right_cmd = spec.on_right_click.clone();
            let middle_cmd = spec.on_middle_click.clone();

            event_box.connect_button_press_event(move |_, event| {
                match event.button() {
                    1 => { if let Some(cmd) = &left_cmd   { run_command(cmd); } }
                    2 => { if let Some(cmd) = &middle_cmd { run_command(cmd); } }
                    3 => { if let Some(cmd) = &right_cmd  { run_command(cmd); } }
                    _ => {}
                }
                glib::Propagation::Proceed
            });
        }

        inner.add(&event_box);
        event_box.show_all();
    }

    inner.show();
}

fn run_command(cmd: &str) {
    if let Err(e) = std::process::Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .spawn()
    {
        eprintln!("waybar-dynamic: failed to run command {:?}: {}", cmd, e);
    }
}

waybar_module!(DynamicModule);