waybar-dynamic 0.1.0

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

use waybar_dynamic_core::protocol::WidgetSpec;

/// Shared widget state, accessible from both the IPC listener and the GTK thread.
#[derive(Debug, Default, Clone)]
pub struct SharedState(pub Arc<Mutex<Vec<WidgetSpec>>>);

impl SharedState {
    pub fn new() -> Self {
        Self(Arc::new(Mutex::new(Vec::new())))
    }

    /// Replace the entire widget list.
    pub fn set_all(&self, widgets: Vec<WidgetSpec>) {
        *self.0.lock().expect("poisoned state mutex") = widgets;
    }

    /// Apply a patch: upsert widgets by id, then remove by id.
    /// Widgets without an id in the upsert list are appended.
    pub fn patch(&self, upsert: Vec<WidgetSpec>, remove: Vec<String>) {
        let mut list = self.0.lock().expect("poisoned state mutex");

        let remove_set: HashSet<&str> = remove.iter().map(|s| s.as_str()).collect();

        list.retain(|w| {
            w.id.as_deref()
                .map(|id| !remove_set.contains(id))
                .unwrap_or(true)
        });

        for spec in upsert {
            match &spec.id {
                Some(id) => {
                    if let Some(existing) =
                        list.iter_mut().find(|w| w.id.as_deref() == Some(id))
                    {
                        *existing = spec;
                    } else {
                        list.push(spec);
                    }
                }
                None => list.push(spec),
            }
        }
    }

    /// Clear all widgets.
    pub fn clear(&self) {
        self.0.lock().expect("poisoned state mutex").clear();
    }

    /// Return a snapshot of the current widget list.
    pub fn snapshot(&self) -> Vec<WidgetSpec> {
        self.0.lock().expect("poisoned state mutex").clone()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn widget(id: &str, label: &str) -> WidgetSpec {
        WidgetSpec {
            id: Some(id.to_string()),
            label: label.to_string(),
            classes: vec![],
            tooltip: None,
            on_click: None,
            on_right_click: None,
            on_middle_click: None,
        }
    }

    fn anon_widget(label: &str) -> WidgetSpec {
        WidgetSpec {
            id: None,
            label: label.to_string(),
            classes: vec![],
            tooltip: None,
            on_click: None,
            on_right_click: None,
            on_middle_click: None,
        }
    }

    #[test]
    fn set_all_replaces_state() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        assert_eq!(state.snapshot().len(), 1);

        state.set_all(vec![widget("b", "B"), widget("c", "C")]);
        let snap = state.snapshot();
        assert_eq!(snap.len(), 2);
        assert_eq!(snap[0].label, "B");
    }

    #[test]
    fn clear_empties_state() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        state.clear();
        assert!(state.snapshot().is_empty());
    }

    #[test]
    fn patch_upsert_new() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        state.patch(vec![widget("b", "B")], vec![]);
        let snap = state.snapshot();
        assert_eq!(snap.len(), 2);
    }

    #[test]
    fn patch_upsert_existing() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "old")]);
        state.patch(vec![widget("a", "new")], vec![]);
        let snap = state.snapshot();
        assert_eq!(snap.len(), 1);
        assert_eq!(snap[0].label, "new");
    }

    #[test]
    fn patch_remove() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A"), widget("b", "B"), widget("c", "C")]);
        state.patch(vec![], vec!["b".to_string()]);
        let snap = state.snapshot();
        assert_eq!(snap.len(), 2);
        assert!(snap.iter().all(|w| w.id.as_deref() != Some("b")));
    }

    #[test]
    fn patch_upsert_and_remove() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A"), widget("b", "B")]);
        state.patch(vec![widget("c", "C")], vec!["a".to_string()]);
        let snap = state.snapshot();
        assert_eq!(snap.len(), 2);
        let ids: Vec<_> = snap.iter().filter_map(|w| w.id.as_deref()).collect();
        assert!(ids.contains(&"b"));
        assert!(ids.contains(&"c"));
    }

    #[test]
    fn patch_anonymous_widgets_appended() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        state.patch(vec![anon_widget("X"), anon_widget("Y")], vec![]);
        assert_eq!(state.snapshot().len(), 3);
    }

    #[test]
    fn patch_empty_is_noop() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        state.patch(vec![], vec![]);
        assert_eq!(state.snapshot().len(), 1);
    }

    #[test]
    fn patch_remove_nonexistent_is_noop() {
        let state = SharedState::new();
        state.set_all(vec![widget("a", "A")]);
        state.patch(vec![], vec!["zzz".to_string()]);
        assert_eq!(state.snapshot().len(), 1);
    }
}