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

commitbb1ac44d5eec49b0b44f5a0607cb1ee2ccf7ebce
parentac96568428
authorLucas Galante <[email protected]>
date2026-07-13 21:31
refactor: port popups to cce_ui::process::CloudPopup{,Tracker} (proposal phase 4)

The four popup sites — window picker (trigger_switcher + spawn_window_picker),
tray DBus menus (show_cce_cloud_menu), the module context menu, and the
layout-mode menu — now share cce-ui's extracted helpers: CloudPopupTracker
(field cloud_popups) replaces the hand-rolled active_cloud_pid/
active_cloud_source state and the four copies of the /proc + comm + kill
toggle dance; CloudPopup::run_json/run_dmenu replace the four spawn/stdin/
wait_with_output blocks. CloudSpawned/CloudClosed events now just feed
tracker.on_spawned/on_closed; focus-restore stays behind on_closed's
confirmation.

Deliberate unifications (previously the sites disagreed):
- the window picker now kills another source's open popup before opening
  (the other three already did); before, that popup was orphaned untracked
- the picker toggle now checks /proc/<pid>/comm like the other sites,
  guarding against pid reuse
- the layout/context menus track their pid via CloudSpawned instead of
  synchronously, so a click during spawn cancels cleanly (on_spawned kills
  the late pid) instead of racing
- popup stderr is uniformly piped and logged at debug

get_cce_cloud_cmd and the SIGTERM helper moved into cce-ui (libc dep dropped
here). Requires cce-ui 5272446.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md     |  10 +-
 Cargo.toml    |   1 -
 src/cloud.rs  | 144 +++++++---------------------
 src/config.rs |  12 +--
 src/main.rs   | 304 +++++++++++++---------------------------------------------
 5 files changed, 113 insertions(+), 358 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9066c4f..9bf015e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,10 +93,12 @@ both). Keyboard alt-tab switching is delegated to the compositor
 (`ccectl window-switcher`) — don't reimplement it here.
 
 **Popups are `cce-cloud` processes**, not surfaces of this app: the window picker, tray
-context menus, and the layout-mode menu each spawn `cce-cloud`, pipe it a JSON page
-description on stdin, and track it via the `CloudSpawned`/`CloudClosed` events
-(`active_cloud_pid`/`active_cloud_source`). Clicking again toggles the popup off by
-killing the pid; closing restores focus with `ccectl focus-window`. Follow this pattern
+context menus, and the layout-mode menu each run a `cce_ui::process::CloudPopup`
+(`run_json`/`run_dmenu`) on a worker thread, and the single-popup toggle state lives in
+`cce_ui::process::CloudPopupTracker` (`StatusApp.cloud_popups`) — the thread reports
+back via the `CloudSpawned`/`CloudClosed` events, which feed
+`tracker.on_spawned`/`on_closed`. Clicking a trigger again toggles its popup off
+(`tracker.click`); closing restores focus with `ccectl focus-window`. Follow this pattern
 for any new popup.
 
 ## Config
diff --git a/Cargo.toml b/Cargo.toml
index b71975e..22f1204 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,6 @@ tokio-stream = "0.1"
 zbus = "4"
 resvg = "0.41.0"
 png = "0.17"
-libc = "0.2"
 log = "0.4"
 env_logger = "0.11"
 kdl = "4.6"
diff --git a/src/cloud.rs b/src/cloud.rs
index 11774a5..c3b7c0a 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -2,7 +2,7 @@
 //! a `cce-cloud` process fed JSON pages on stdin.
 
 use crate::{parse_ccectl_windows, CustomEvent};
-use crate::config::{get_cce_cloud_cmd, get_ccectl_cmd};
+use crate::config::get_ccectl_cmd;
 
 #[zbus::proxy(
     interface = "com.canonical.dbusmenu",
@@ -105,18 +105,6 @@ pub(crate) fn get_currently_focused_window() -> Option<String> {
         .map(|(id, _, _, _)| id)
 }
 
-/// Send SIGTERM to `pid` directly instead of shelling out to `kill`,
-/// logging when the signal cannot be delivered.
-pub(crate) fn send_sigterm(pid: u32) {
-    let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
-    if ret != 0 {
-        log::warn!(
-            "[cloud] SIGTERM to pid {} failed: {}",
-            pid,
-            std::io::Error::last_os_error()
-        );
-    }
-}
 
 pub(crate) async fn show_cce_cloud_menu(
     conn: &zbus::Connection,
@@ -304,39 +292,20 @@ pub(crate) async fn show_cce_cloud_menu(
         });
         let layout_str = layout_json.to_string();
 
-        let mut cmd_args = vec![
-            "--json".to_string(),
-            "-x".to_string(),
-            x_pos.to_string(),
-            "-y".to_string(),
-            y_pos.to_string(),
-            "--parent-app-id".to_string(),
-            parent_app_id,
-        ];
+        let mut popup = cce_ui::process::CloudPopup::at(x_pos, y_pos)
+            .parent_app_id(parent_app_id);
         if align_right {
-            cmd_args.push("--align-right".to_string());
-        }
-
-        let mut child = std::process::Command::new(get_cce_cloud_cmd())
-            .args(&cmd_args)
-            .stdin(std::process::Stdio::piped())
-            .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::inherit())
-            .spawn()?;
-
-        let pid = child.id();
-        last_spawned_pid = pid;
-        let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
-
-        if let Some(mut stdin) = child.stdin.take() {
-            use std::io::Write;
-            stdin.write_all(layout_str.as_bytes())?;
+            popup = popup.align_right();
         }
-
-        let output = child.wait_with_output()?;
-        if output.status.success() {
-            let stdout_str = String::from_utf8_lossy(&output.stdout);
-            if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(stdout_str.trim()) {
+        // run_json blocks this thread, like the wait_with_output it replaces —
+        // fine, show_cce_cloud_menu runs on its own single-purpose runtime.
+        let output = popup.run_json(&layout_str, |pid| {
+            last_spawned_pid = pid;
+            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
+        })?;
+
+        if let Some(stdout_str) = output {
+            if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
                 if let Some(btn_id) = parsed_json.get("button").and_then(|v| v.as_str()) {
                     if btn_id.starts_with("item_") {
                         if let Ok(item_id) = btn_id["item_".len()..].parse::<i32>() {
@@ -398,74 +367,35 @@ pub(crate) fn spawn_window_picker(
             input_str.push('\n');
         }
 
-        let 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(),
-        ];
-
-        let mut child = match std::process::Command::new(get_cce_cloud_cmd())
-            .args(&cmd_args)
-            .stdin(std::process::Stdio::piped())
-            .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::inherit())
-            .spawn()
-        {
-            Ok(c) => c,
-            Err(e) => {
-                log::warn!("[switcher] Failed to spawn cce-cloud: {:?}", e);
-                let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: source.clone() });
-                return;
-            }
-        };
-
-        let pid = child.id();
-        let mut stdin = child.stdin.take().unwrap();
-        let mut stdout = child.stdout.take().unwrap();
-
-        // Write the item list, then drop stdin so cce-cloud sees EOF.
-        use std::io::Write;
-        let _ = stdin.write_all(input_str.as_bytes());
-        let _ = stdin.flush();
-        drop(stdin);
-
-        // 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 popup = cce_ui::process::CloudPopup::at(x_pos, y_pos);
+        let mut spawned_pid = 0;
+        let result = popup.run_dmenu("Windows:", &input_str, |pid| {
+            spawned_pid = pid;
+            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
         });
 
-        let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: source.clone() });
-
-        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 (id, app_id, title, _) in windows {
-                let display = if title.is_empty() {
-                    app_id.clone()
-                } else {
-                    format!("{} ({})", title, app_id)
-                };
-                if display == selected {
-                    log::debug!("[switcher] Selecting window title: {}, app_id: {}, id: {}", title, app_id, id);
-                    let _ = std::process::Command::new(get_ccectl_cmd())
-                        .args(["focus-window", &id])
-                        .spawn();
-                    break;
+        match result {
+            Ok(Some(selected)) => {
+                // Find the matched window
+                for (id, app_id, title, _) in windows {
+                    let display = if title.is_empty() {
+                        app_id.clone()
+                    } else {
+                        format!("{} ({})", title, app_id)
+                    };
+                    if display == selected {
+                        log::debug!("[switcher] Selecting window title: {}, app_id: {}, id: {}", title, app_id, id);
+                        let _ = std::process::Command::new(get_ccectl_cmd())
+                            .args(["focus-window", &id])
+                            .spawn();
+                        break;
+                    }
                 }
             }
+            Ok(None) => {}
+            Err(e) => log::warn!("[switcher] Failed to spawn cce-cloud: {:?}", e),
         }
 
-        let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: source.clone() });
+        let _ = thread_sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: source.clone() });
     });
 }
diff --git a/src/config.rs b/src/config.rs
index 957d6c5..6c492b4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,5 +1,5 @@
 //! Config access: cached KDL config lookup, color/font/dimension readers,
-//! and resolution of the cce/ccectl/cce-cloud binaries.
+//! and resolution of the ccectl binary.
 //!
 //! Every key has an explicit JSON-pointer location (the canonical nesting in
 //! config.kdl). Reads try the pointer first and fall back to the legacy fuzzy
@@ -225,16 +225,6 @@ pub(crate) fn read_status_box_corner_radius_from_config() -> f32 {
     cfg_f32("/style/status/box_corner_radius", "status_box_corner_radius").unwrap_or(4.0)
 }
 
-pub(crate) fn get_cce_cloud_cmd() -> String {
-    if let Ok(home) = std::env::var("HOME") {
-        let path = format!("{}/.local/bin/cce-cloud", home);
-        if std::path::Path::new(&path).exists() {
-            return path;
-        }
-    }
-    "cce-cloud".to_string()
-}
-
 pub(crate) fn get_ccectl_cmd() -> String {
     if let Ok(home) = std::env::var("HOME") {
         let path = format!("{}/.local/bin/ccectl", home);
diff --git a/src/main.rs b/src/main.rs
index e8fa256..91ad33a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -204,8 +204,7 @@ struct StatusApp {
     tray_item_bounds: Vec<TrayIconBounds>,
     viewport_bounds: Vec<ViewportBounds>,
     layout_bounds: Option<LayoutBounds>,
-    active_cloud_pid: Option<u32>,
-    active_cloud_source: Option<String>,
+    cloud_popups: cce_ui::process::CloudPopupTracker,
     previously_focused_window: Option<String>,
 
     font_system: FontSystem,
@@ -731,25 +730,10 @@ impl StatusApp {
         // click-to-pick list of the current windows.
         let switcher_source = "window".to_string();
 
-        // If a picker is already open (or pending) for this source, toggle it off.
-        let running = self
-            .active_cloud_pid
-            .map_or(false, |pid| std::path::Path::new(&format!("/proc/{}", pid)).exists());
-        if self.active_cloud_source.as_ref() == Some(&switcher_source) {
-            if running {
-                if let Some(pid) = self.active_cloud_pid {
-                    log::debug!("[window-picker] Toggling off existing cce-cloud PID {}", pid);
-                    send_sigterm(pid);
-                }
-                self.active_cloud_pid = None;
-            }
-            self.active_cloud_source = None;
+        if self.cloud_popups.click(&switcher_source) == cce_ui::process::CloudPopupClick::ToggledOff {
             return;
         }
 
-        // Set active cloud source
-        self.active_cloud_source = Some(switcher_source.clone());
-
         // Get placement coords: align just below Window module if we can find it
         let mut target_x = 0.0;
         for mb in &self.module_bounds {
@@ -1053,8 +1037,7 @@ impl cce_ui::engine::Application for StatusApp {
             tray_item_bounds: Vec::new(),
             viewport_bounds: Vec::new(),
             layout_bounds: None,
-            active_cloud_pid: None,
-            active_cloud_source: None,
+            cloud_popups: cce_ui::process::CloudPopupTracker::new(),
             previously_focused_window: None,
             font_system,
             status_bar: cce_ui::widget::StatusBar::new(),
@@ -1133,19 +1116,11 @@ impl cce_ui::engine::Application for StatusApp {
                 self.tray_items.remove(&id);
             }
             CustomEvent::CloudSpawned { pid, source } => {
-                if self.active_cloud_source.as_ref() == Some(&source) {
-                    log::debug!("[cloud-event] CloudSpawned: pid {} for source {} matches expected, tracking", pid, source);
-                    self.active_cloud_pid = Some(pid);
-                } else {
-                    log::debug!("[cloud-event] CloudSpawned: pid {} for source {} is obsolete/canceled, killing", pid, source);
-                    send_sigterm(pid);
-                }
+                self.cloud_popups.on_spawned(pid, &source);
             }
             CustomEvent::CloudClosed { pid, source } => {
-                if self.active_cloud_pid == Some(pid) || (pid == 0 && self.active_cloud_source.as_ref() == Some(&source)) {
+                if self.cloud_popups.on_closed(pid, &source) {
                     log::debug!("[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.previously_focused_window = None;
                     } else if source == "layout" || source.starts_with("context_menu:") || source.starts_with("tray:") {
@@ -1349,41 +1324,9 @@ impl cce_ui::engine::Application for StatusApp {
                 let id = bound.id.clone();
                 let tray_source = format!("tray:{}", id);
 
-                // Check if any cce-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() == "cce-cloud" {
-                                running_cloud_pid = Some(pid);
-                            }
-                        }
-                    }
-                }
-
-                if let Some(pid) = running_cloud_pid {
-                    // There is an active dialog open.
-                    // Kill it regardless of which one it is.
-                    log::debug!("[tray-click] cce-cloud (PID {}) is running, killing it", pid);
-                    send_sigterm(pid);
-                    self.active_cloud_pid = None;
-
-                    // If it was clicked for the SAME tray icon, this is a toggle-off.
-                    if self.active_cloud_source.as_ref() == Some(&tray_source) {
-                        self.active_cloud_source = None;
-                        return None;
-                    }
-                } else {
-                    // No active dialog is running, but check if there is a pending one for the same source
-                    if self.active_cloud_source.as_ref() == Some(&tray_source) {
-                        // User clicked same icon again while it was pending. Cancel it!
-                        self.active_cloud_source = None;
-                        return None;
-                    }
+                if self.cloud_popups.click(&tray_source) == cce_ui::process::CloudPopupClick::ToggledOff {
+                    return None;
                 }
-
-                // Now set the active cloud source to this one
-                self.active_cloud_source = Some(tray_source.clone());
                 if self.previously_focused_window.is_none() {
                     self.previously_focused_window = get_currently_focused_window();
                 }
@@ -1489,37 +1432,10 @@ impl cce_ui::engine::Application for StatusApp {
                     log::debug!("[module-right-click] Right-clicked module: {}", mb.name);
                     let context_source = format!("context_menu:{}", mb.name);
 
-                    // Check if any cce-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() == "cce-cloud" {
-                                    running_cloud_pid = Some(pid);
-                                }
-                            }
-                        }
-                    }
-
-                    if let Some(pid) = running_cloud_pid {
-                        log::debug!("[module-right-click] cce-cloud (PID {}) is running, killing it", pid);
-                        send_sigterm(pid);
-                        self.active_cloud_pid = None;
-
-                        // If it was clicked for the same context menu, this is a toggle-off
-                        if self.active_cloud_source.as_ref() == Some(&context_source) {
-                            self.active_cloud_source = None;
-                            return None;
-                        }
-                    } else {
-                        if self.active_cloud_source.as_ref() == Some(&context_source) {
-                            self.active_cloud_source = None;
-                            return None;
-                        }
+                    if self.cloud_popups.click(&context_source) == cce_ui::process::CloudPopupClick::ToggledOff {
+                        return None;
                     }
 
-                    self.active_cloud_source = Some(context_source.clone());
-
                     if self.previously_focused_window.is_none() {
                         self.previously_focused_window = get_currently_focused_window();
                     }
@@ -1553,54 +1469,29 @@ impl cce_ui::engine::Application for StatusApp {
                     };
 
                     let parent_app_id = self.get_app_id();
-
-                    if let Ok(mut child) = std::process::Command::new(get_cce_cloud_cmd())
-                        .args([
-                            "--json",
-                            "-x",
-                            &x_pos.to_string(),
-                            "-y",
-                            &y_pos.to_string(),
-                            "--parent-app-id",
-                            &parent_app_id,
-                        ])
-                        .stdin(std::process::Stdio::piped())
-                        .stdout(std::process::Stdio::piped())
-                        .stderr(std::process::Stdio::piped())
-                        .spawn()
-                    {
-                        let pid = child.id();
-                        self.active_cloud_pid = Some(pid);
-                        log::debug!("[module-right-click] Spawned cce-cloud with PID {}", pid);
-
-                        let thread_sender = self.sender.clone();
-                        let context_source_clone = context_source.clone();
-                        std::thread::spawn(move || {
-                            if let Some(mut stdin) = child.stdin.take() {
-                                use std::io::Write;
-                                let _ = stdin.write_all(context_json.as_bytes());
-                            }
-                            if let Ok(output) = child.wait_with_output() {
-                                let err_str = String::from_utf8_lossy(&output.stderr);
-                                if !err_str.is_empty() {
-                                    log::debug!("[cce-cloud context stderr] {}", err_str);
-                                }
-                                if output.status.success() {
-                                    let stdout_str = String::from_utf8_lossy(&output.stdout);
-                                    if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(stdout_str.trim()) {
-                                        if let Some(btn_id) = parsed_json.get("button").and_then(|v| v.as_str()) {
-                                            if btn_id == "toggle_hide" {
-                                                let _ = thread_sender.send(CustomEvent::ToggleHideModules);
-                                            } else if btn_id == "toggle_adjust" {
-                                                let _ = thread_sender.send(CustomEvent::ToggleAdjustPositionMode);
-                                            }
-                                        }
+                    let thread_sender = self.sender.clone();
+                    std::thread::spawn(move || {
+                        let popup = cce_ui::process::CloudPopup::at(x_pos, y_pos)
+                            .parent_app_id(parent_app_id);
+                        let mut spawned_pid = 0;
+                        let result = popup.run_json(&context_json, |pid| {
+                            spawned_pid = pid;
+                            log::debug!("[module-right-click] Spawned cce-cloud with PID {}", pid);
+                            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: context_source.clone() });
+                        });
+                        if let Ok(Some(out_str)) = &result {
+                            if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(out_str) {
+                                if let Some(btn_id) = parsed_json.get("button").and_then(|v| v.as_str()) {
+                                    if btn_id == "toggle_hide" {
+                                        let _ = thread_sender.send(CustomEvent::ToggleHideModules);
+                                    } else if btn_id == "toggle_adjust" {
+                                        let _ = thread_sender.send(CustomEvent::ToggleAdjustPositionMode);
                                     }
                                 }
                             }
-                            let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: context_source_clone });
-                        });
-                    }
+                        }
+                        let _ = thread_sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: context_source });
+                    });
 
                     return None;
                 }
@@ -1622,41 +1513,10 @@ impl cce_ui::engine::Application for StatusApp {
                     log::debug!("[layout-click] Layout mode clicked!");
                     let layout_source = "layout".to_string();
 
-                    // Check if any cce-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() == "cce-cloud" {
-                                    running_cloud_pid = Some(pid);
-                                }
-                            }
-                        }
-                    }
-
-                    if let Some(pid) = running_cloud_pid {
-                        // There is an active dialog open.
-                        // Kill it regardless of which one it is.
-                        log::debug!("[layout-click] cce-cloud (PID {}) is running, killing it", pid);
-                        send_sigterm(pid);
-                        self.active_cloud_pid = None;
-
-                        // If it was clicked for the layout menu, this is a toggle-off.
-                        if self.active_cloud_source.as_ref() == Some(&layout_source) {
-                            self.active_cloud_source = None;
-                            return None;
-                        }
-                    } else {
-                        // No active dialog is running, but check if there is a pending one for the same source
-                        if self.active_cloud_source.as_ref() == Some(&layout_source) {
-                            self.active_cloud_source = None;
-                            return None;
-                        }
+                    if self.cloud_popups.click(&layout_source) == cce_ui::process::CloudPopupClick::ToggledOff {
+                        return None;
                     }
 
-                    // Now set the active cloud source to this one
-                    self.active_cloud_source = Some(layout_source);
-
                     if self.previously_focused_window.is_none() {
                         self.previously_focused_window = get_currently_focused_window();
                     }
@@ -1664,7 +1524,6 @@ impl cce_ui::engine::Application for StatusApp {
                     let x_pos = self.layout_bounds.as_ref().map(|b| b.x as i32).unwrap_or(0);
                     let y_pos = self.layout_bounds.as_ref().map(|b| b.h as i32).unwrap_or_else(|| read_status_height_from_config() as i32);
                     
-                    // Spawn the child on the main thread so we can capture its PID
                     let layout_json = serde_json::json!({
                         "width": 240,
                         "height": 320,
@@ -1679,72 +1538,47 @@ impl cce_ui::engine::Application for StatusApp {
                         ]
                     }).to_string();
 
-                    if let Ok(mut child) = std::process::Command::new(get_cce_cloud_cmd())
-                        .args([
-                            "--json",
-                            "-x",
-                            &x_pos.to_string(),
-                            "-y",
-                            &y_pos.to_string(),
-                        ])
-                        .stdin(std::process::Stdio::piped())
-                        .stdout(std::process::Stdio::piped())
-                        .stderr(std::process::Stdio::piped())
-                        .spawn()
-                    {
-                        let pid = child.id();
-                        self.active_cloud_pid = Some(pid);
-                        log::debug!("[layout-click] Spawned cce-cloud with PID {}", pid);
-                        
-                        let active_viewport = get_active_viewport_from_camera(&self.viewport);
-                        let thread_sender = self.sender.clone();
-                        std::thread::spawn(move || {
-                            log::debug!("[layout-click] Active tag is {}", active_viewport);
-                            if let Some(mut stdin) = child.stdin.take() {
-                                use std::io::Write;
-                                let _ = stdin.write_all(layout_json.as_bytes());
+                    let active_viewport = get_active_viewport_from_camera(&self.viewport);
+                    let thread_sender = self.sender.clone();
+                    std::thread::spawn(move || {
+                        log::debug!("[layout-click] Active tag is {}", active_viewport);
+                        let popup = cce_ui::process::CloudPopup::at(x_pos, y_pos);
+                        let mut spawned_pid = 0;
+                        let result = popup.run_json(&layout_json, |pid| {
+                            spawned_pid = pid;
+                            log::debug!("[layout-click] Spawned cce-cloud with PID {}", pid);
+                            let _ = thread_sender.send(CustomEvent::CloudSpawned { pid, source: layout_source.clone() });
+                        });
+                        if let Ok(Some(out_str)) = &result {
+                            #[derive(serde::Deserialize)]
+                            struct LayoutMenuOutput {
+                                button: String,
+                                checkboxes: std::collections::HashMap<String, bool>,
                             }
-                            if let Ok(output) = child.wait_with_output() {
-                                let err_str = String::from_utf8_lossy(&output.stderr);
-                                if !err_str.is_empty() {
-                                    log::debug!("[cce-cloud stderr] {}", err_str);
-                                }
-                                if output.status.success() {
-                                    let out_str = String::from_utf8_lossy(&output.stdout);
-                                    #[derive(serde::Deserialize)]
-                                    struct LayoutMenuOutput {
-                                        button: String,
-                                        checkboxes: std::collections::HashMap<String, bool>,
-                                    }
-                                    if let Ok(val) = serde_json::from_str::<LayoutMenuOutput>(out_str.trim()) {
-                                        let selected_mode = val.button.to_lowercase();
-                                        let apply_all = val.checkboxes.get("apply_all").copied().unwrap_or(false);
-                                        if apply_all {
-                                            log::debug!("[layout-click] Selected mode: {}, applying to all windows sharing mode", selected_mode);
-                                            let _ = std::process::Command::new(get_ccectl_cmd())
-                                                .args(["apply-mode-sharing", &selected_mode])
-                                                .spawn();
-                                        } else {
-                                            log::debug!("[layout-click] Selected mode: {}, setting for tag {}", selected_mode, active_viewport);
-                                            let _ = std::process::Command::new(get_ccectl_cmd())
-                                                .args(["viewport-layout", &active_viewport.to_string(), &selected_mode])
-                                                .spawn();
-                                        }
-                                    } else {
-                                        // Fallback
-                                        let selected = out_str.trim().to_string();
-                                        if !selected.is_empty() {
-                                            let selected_lower = selected.to_lowercase();
-                                            let _ = std::process::Command::new(get_ccectl_cmd())
-                                                .args(["viewport-layout", &active_viewport.to_string(), &selected_lower])
-                                                .spawn();
-                                        }
-                                    }
+                            if let Ok(val) = serde_json::from_str::<LayoutMenuOutput>(out_str) {
+                                let selected_mode = val.button.to_lowercase();
+                                let apply_all = val.checkboxes.get("apply_all").copied().unwrap_or(false);
+                                if apply_all {
+                                    log::debug!("[layout-click] Selected mode: {}, applying to all windows sharing mode", selected_mode);
+                                    let _ = std::process::Command::new(get_ccectl_cmd())
+                                        .args(["apply-mode-sharing", &selected_mode])
+                                        .spawn();
+                                } else {
+                                    log::debug!("[layout-click] Selected mode: {}, setting for tag {}", selected_mode, active_viewport);
+                                    let _ = std::process::Command::new(get_ccectl_cmd())
+                                        .args(["viewport-layout", &active_viewport.to_string(), &selected_mode])
+                                        .spawn();
                                 }
+                            } else {
+                                // Fallback
+                                let selected_lower = out_str.to_lowercase();
+                                let _ = std::process::Command::new(get_ccectl_cmd())
+                                    .args(["viewport-layout", &active_viewport.to_string(), &selected_lower])
+                                    .spawn();
                             }
-                            let _ = thread_sender.send(CustomEvent::CloudClosed { pid, source: "layout".to_string() });
-                        });
-                    }
+                        }
+                        let _ = thread_sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: layout_source });
+                    });
                 } else {
                     let mut clicked_window = false;
                     for mb in &self.module_bounds {