git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit9a00ad62cc305b9814eb135ddb7f6177d9fb3729
parentbb8f0265f9
authorLucas Galante <[email protected]>
date2026-06-24 21:19
feat: implement Unix socket trigger, title click and channel-based stdin switcher integration

 src/main.rs | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 292 insertions(+), 16 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index befc5b0..85f3467 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -71,6 +71,15 @@ pub struct SystemStats {
     pub brightness: String,
 }
 
+#[derive(Clone)]
+struct StdinWriter(std::sync::mpsc::Sender<String>);
+
+impl std::fmt::Debug for StdinWriter {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "StdinWriter")
+    }
+}
+
 #[derive(Debug, Clone)]
 enum CustomEvent {
     TagsUpdated(String),
@@ -80,8 +89,9 @@ enum CustomEvent {
     SystemStatsUpdated(SystemStats),
     TrayUpdated(TrayItem),
     TrayRemoved(String),
-    CloudSpawned { pid: u32, source: String },
+    CloudSpawned { pid: u32, source: String, switcher_stdin: Option<StdinWriter> },
     CloudClosed { pid: u32, source: String },
+    SwitcherTriggered,
 }
 
 pub(crate) fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font_family: &str) -> Buffer {
@@ -272,6 +282,7 @@ struct StatusApp {
     layout_bounds: Option<LayoutBounds>,
     active_cloud_pid: Option<u32>,
     active_cloud_source: Option<String>,
+    active_switcher_stdin: Option<StdinWriter>,
 
     font_system: FontSystem,
     status_bar: cce_ui::widget::StatusBar,
@@ -667,6 +678,206 @@ impl StatusApp {
         }
         false
     }
+
+    fn trigger_switcher(&mut self, is_switcher_mode: bool) {
+        let switcher_source = "window".to_string();
+
+        // 1. Check if any clear-cloud instance is already running
+        let mut running_cloud_pid = None;
+        if let Some(pid) = self.active_cloud_pid {
+            if std::path::Path::new(&format!("/proc/{}", pid)).exists() {
+                if let Ok(comm) = std::fs::read_to_string(format!("/proc/{}/comm", pid)) {
+                    if comm.trim() == "clear-cloud" {
+                        running_cloud_pid = Some(pid);
+                    }
+                }
+            }
+        }
+
+        if let Some(pid) = running_cloud_pid {
+            if is_switcher_mode && self.active_cloud_source.as_ref() == Some(&switcher_source) {
+                if let Some(ref writer) = self.active_switcher_stdin {
+                    eprintln!("[switcher] Already open, sending cycle command to clear-cloud stdin");
+                    let _ = writer.0.send("__cce_switcher_next__\n".to_string());
+                    return;
+                }
+            } else {
+                // Kill it to switch focus
+                eprintln!("[switcher] Killing existing clear-cloud PID {}", pid);
+                let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
+                self.active_cloud_pid = None;
+                
+                // If it was a normal click on the same thing, toggle it off
+                if !is_switcher_mode && self.active_cloud_source.as_ref() == Some(&switcher_source) {
+                    self.active_cloud_source = None;
+                    return;
+                }
+            }
+        } else {
+            // No active dialog is running, check if pending for same source
+            if !is_switcher_mode && self.active_cloud_source.as_ref() == Some(&switcher_source) {
+                self.active_cloud_source = None;
+                return;
+            }
+        }
+
+        // Set active cloud source
+        self.active_cloud_source = Some(switcher_source.clone());
+
+        // Get placement coords: align just below Title module if we can find it
+        let mut target_x = 0.0;
+        for mb in &self.module_bounds {
+            if mb.name == "title" {
+                target_x = mb.x;
+                break;
+            }
+        }
+
+        let bar_height = read_status_height_from_config() as i32;
+
+        // Position it under Title module
+        let x_pos = target_x as i32;
+        let y_pos = bar_height;
+
+        let thread_sender = self.sender.clone();
+        let switcher_source_clone = switcher_source.clone();
+
+        std::thread::spawn(move || {
+            // Run "clearctl windows" to fetch the windows list
+            let output = std::process::Command::new("clearctl")
+                .arg("windows")
+                .output();
+
+            let mut windows = Vec::new();
+            if let Ok(out) = output {
+                let stdout_str = String::from_utf8_lossy(&out.stdout);
+                for line in stdout_str.lines() {
+                    let app_id = if let Some(idx) = line.find("app_id=") {
+                        let rest = &line[idx + 7..];
+                        let end = rest.find(' ').unwrap_or(rest.len());
+                        rest[..end].to_string()
+                    } else {
+                        continue;
+                    };
+
+                    if app_id == "cce-status-interface" || app_id == "cce-cloud" {
+                        continue;
+                    }
+
+                    let title = if let Some(idx) = line.find("title=\"") {
+                        let rest = &line[idx + 7..];
+                        let end = rest.find('"').unwrap_or(rest.len());
+                        rest[..end].to_string()
+                    } else {
+                        "".to_string()
+                    };
+
+                    windows.push((app_id, title));
+                }
+            }
+
+            if windows.is_empty() {
+                // If there are no windows, don't open a switcher and clear state
+                let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: switcher_source_clone });
+                return;
+            }
+
+            // Format items for dmenu
+            let mut input_str = String::new();
+            for (app_id, title) in &windows {
+                let display = if title.is_empty() {
+                    app_id.clone()
+                } else {
+                    format!("{} ({})", title, app_id)
+                };
+                input_str.push_str(&display);
+                input_str.push('\n');
+            }
+
+            let mut cmd_args = vec![
+                "--dmenu".to_string(),
+                "-p".to_string(),
+                "Windows:".to_string(),
+                "-x".to_string(),
+                x_pos.to_string(),
+                "-y".to_string(),
+                y_pos.to_string(),
+            ];
+            if is_switcher_mode {
+                cmd_args.push("--switcher".to_string());
+            }
+
+            let mut child = match std::process::Command::new(get_clear_cloud_cmd())
+                .args(&cmd_args)
+                .stdin(std::process::Stdio::piped())
+                .stdout(std::process::Stdio::piped())
+                .stderr(std::process::Stdio::piped())
+                .spawn()
+            {
+                Ok(c) => c,
+                Err(e) => {
+                    eprintln!("[switcher] Failed to spawn clear-cloud: {:?}", e);
+                    let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: switcher_source_clone });
+                    return;
+                }
+            };
+
+            let pid = child.id();
+            let mut stdin = child.stdin.take().unwrap();
+            let mut stdout = child.stdout.take().unwrap();
+
+            // Create channels for stdin writing and spawn forwarder
+            let (stdin_tx, stdin_rx) = std::sync::mpsc::channel::<String>();
+            use std::io::Write;
+            let _ = stdin.write_all(input_str.as_bytes());
+            let _ = stdin.flush();
+
+            std::thread::spawn(move || {
+                while let Ok(msg) = stdin_rx.recv() {
+                    if stdin.write_all(msg.as_bytes()).is_err() {
+                        break;
+                    }
+                    let _ = stdin.flush();
+                }
+            });
+
+            // Spawn stdout reader
+            let (stdout_tx, stdout_rx) = std::sync::mpsc::channel();
+            std::thread::spawn(move || {
+                let mut out_str = String::new();
+                use std::io::Read;
+                let _ = stdout.read_to_string(&mut out_str);
+                let _ = stdout_tx.send(out_str);
+            });
+
+            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: switcher_source_clone.clone(), switcher_stdin: Some(StdinWriter(stdin_tx)) });
+
+            let _ = child.wait();
+            let stdout_str = stdout_rx.recv().unwrap_or_default();
+
+            let selected = stdout_str.trim().to_string();
+            if !selected.is_empty() {
+                // Find the matched window
+                for (app_id, title) in windows {
+                    let display = if title.is_empty() {
+                        app_id.clone()
+                    } else {
+                        format!("{} ({})", title, app_id)
+                    };
+                    if display == selected {
+                        eprintln!("[switcher] Selecting window title: {}, app_id: {}", title, app_id);
+                        let focus_query = if title.is_empty() { app_id } else { title };
+                        let _ = std::process::Command::new("clearctl")
+                            .args(["focus-window", &focus_query])
+                            .spawn();
+                        break;
+                    }
+                }
+            }
+
+            let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: switcher_source_clone });
+        });
+    }
 }
 
 fn get_closest_tag(x: f64, y: f64) -> i32 {
@@ -710,6 +921,7 @@ impl cce_ui::engine::Application for StatusApp {
         let sender_modifiers = sender.clone();
         let sender_stats = sender.clone();
         let sender_tray = sender.clone();
+        let sender_switcher = sender.clone();
 
         tokio::spawn(spawn_status_listener("tags", sender_tags));
         tokio::spawn(spawn_status_listener("layout", sender_layout));
@@ -717,6 +929,7 @@ impl cce_ui::engine::Application for StatusApp {
         tokio::spawn(spawn_status_listener("modifiers", sender_modifiers));
         tokio::spawn(spawn_system_stats(sender_stats));
         tokio::spawn(spawn_status_tray(sender_tray));
+        tokio::spawn(spawn_switcher_listener(sender_switcher));
 
         let font_system = FontSystem::new();
 
@@ -733,6 +946,7 @@ impl cce_ui::engine::Application for StatusApp {
             layout_bounds: None,
             active_cloud_pid: None,
             active_cloud_source: None,
+            active_switcher_stdin: None,
             font_system,
             status_bar: cce_ui::widget::StatusBar::new(),
             rects: Vec::new(),
@@ -804,10 +1018,13 @@ impl cce_ui::engine::Application for StatusApp {
             CustomEvent::TrayRemoved(id) => {
                 self.tray_items.remove(&id);
             }
-            CustomEvent::CloudSpawned { pid, source } => {
+            CustomEvent::CloudSpawned { pid, source, switcher_stdin } => {
                 if self.active_cloud_source.as_ref() == Some(&source) {
                     eprintln!("[cloud-event] CloudSpawned: pid {} for source {} matches expected, tracking", pid, source);
                     self.active_cloud_pid = Some(pid);
+                    if source == "window" {
+                        self.active_switcher_stdin = switcher_stdin;
+                    }
                 } else {
                     eprintln!("[cloud-event] CloudSpawned: pid {} for source {} is obsolete/canceled, killing", pid, source);
                     let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
@@ -818,8 +1035,15 @@ impl cce_ui::engine::Application for StatusApp {
                     eprintln!("[cloud-event] CloudClosed: pid {} for source {} closed, clearing tracking", pid, source);
                     self.active_cloud_pid = None;
                     self.active_cloud_source = None;
+                    if source == "window" {
+                        self.active_switcher_stdin = None;
+                    }
                 }
             }
+            CustomEvent::SwitcherTriggered => {
+                eprintln!("[switcher] SwitcherTriggered event received, calling trigger_switcher");
+                self.trigger_switcher(true);
+            }
         }
         self.needs_rebuild = true;
         *needs_rebuild = true;
@@ -1228,19 +1452,34 @@ impl cce_ui::engine::Application for StatusApp {
                         });
                     }
                 } else {
-                    for bound in &self.tag_bounds {
-                        eprintln!("[tags-click] Checking Tag '{}' bounds: x=[{}..{}], y=[{}..{}]", 
-                            bound.name, bound.x, bound.x + bound.w, bound.y, bound.y + bound.h);
-                        if cx >= bound.x as f64 && cx <= (bound.x + bound.w) as f64
-                            && cy >= bound.y as f64 && cy <= (bound.y + bound.h) as f64 {
-                            eprintln!("[tags-click] Tag matched: {}", bound.name);
-                            let name = bound.name.clone();
-                            std::thread::spawn(move || {
-                                let _ = std::process::Command::new("clearctl")
-                                    .args(["view", &name])
-                                    .spawn();
-                            });
-                            break;
+                    let mut clicked_title = false;
+                    for mb in &self.module_bounds {
+                        if mb.name == "title" {
+                            if lx >= mb.x && lx <= (mb.x + mb.w) {
+                                clicked_title = true;
+                                break;
+                            }
+                        }
+                    }
+
+                    if clicked_title {
+                        eprintln!("[title-click] Title module clicked!");
+                        self.trigger_switcher(false);
+                    } else {
+                        for bound in &self.tag_bounds {
+                            eprintln!("[tags-click] Checking Tag '{}' bounds: x=[{}..{}], y=[{}..{}]", 
+                                bound.name, bound.x, bound.x + bound.w, bound.y, bound.y + bound.h);
+                            if cx >= bound.x as f64 && cx <= (bound.x + bound.w) as f64
+                                && cy >= bound.y as f64 && cy <= (bound.y + bound.h) as f64 {
+                                eprintln!("[tags-click] Tag matched: {}", bound.name);
+                                let name = bound.name.clone();
+                                std::thread::spawn(move || {
+                                    let _ = std::process::Command::new("clearctl")
+                                        .args(["view", &name])
+                                        .spawn();
+                                });
+                                break;
+                            }
                         }
                     }
                 }
@@ -1288,6 +1527,29 @@ async fn spawn_status_listener(sub: &'static str, sender: calloop::channel::Send
     }
 }
 
+async fn spawn_switcher_listener(sender: calloop::channel::Sender<CustomEvent>) {
+    use tokio::io::AsyncBufReadExt;
+    use tokio::net::UnixListener;
+    let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+    let socket_path = format!("/tmp/cce-status-interface-switcher-{}.sock", display);
+    let _ = std::fs::remove_file(&socket_path);
+
+    if let Ok(listener) = UnixListener::bind(&socket_path) {
+        eprintln!("[switcher-listener] Listening on {}", socket_path);
+        loop {
+            if let Ok((stream, _)) = listener.accept().await {
+                let mut reader = tokio::io::BufReader::new(stream);
+                let mut line = String::new();
+                if reader.read_line(&mut line).await.is_ok() {
+                    let _ = sender.send(CustomEvent::SwitcherTriggered);
+                }
+            }
+        }
+    } else {
+        eprintln!("[switcher-listener] Failed to bind to {}", socket_path);
+    }
+}
+
 async fn read_volume() -> Option<(String, bool)> {
     let vol_output = match tokio::process::Command::new("pactl")
         .args(["get-sink-volume", "@DEFAULT_SINK@"])
@@ -1654,7 +1916,7 @@ async fn show_clear_cloud_menu(
 
             let pid = child.id();
             last_spawned_pid = pid;
-            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
+            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone(), switcher_stdin: None });
 
             if let Some(mut stdin) = child.stdin.take() {
                 use std::io::Write;
@@ -2177,6 +2439,20 @@ async fn spawn_status_tray(sender: calloop::channel::Sender<CustomEvent>) {
 }
 
 fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    if args.len() > 1 && args[1] == "--trigger-switcher" {
+        let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
+        rt.block_on(async {
+            let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+            let socket_path = format!("/tmp/cce-status-interface-switcher-{}.sock", display);
+            use tokio::io::AsyncWriteExt;
+            if let Ok(mut stream) = tokio::net::UnixStream::connect(&socket_path).await {
+                let _ = stream.write_all(b"trigger\n").await;
+            }
+        });
+        return;
+    }
+
     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
     let _guard = rt.enter();