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

commit989228b5c1b43f70d44870a93b7490be8b1d4c15
parentb13eefd511
authorLucas Galante <[email protected]>
date2026-08-05 12:55
feat: window picker and layout menu in-surface — cce-cloud fully retired here

The last two cce-cloud popups become in-surface menus on the window
module: the title-click window picker (rows run ccectl focus-window;
the bar's own segments are filtered out) and the layout-indicator menu
(SetMode rows honoring the in-place ToggleApplyAll checkbox row, which
flips [ ]/[x] and stays open; viewport captured at open). Menu width
now sizes to the longest row label instead of a fixed minimum — the
picker's window titles no longer truncate.

With no popups left, the CloudPopupTracker field, the
CloudSpawned/CloudClosed events and their focus-restore juggling,
spawn_window_picker, and get_currently_focused_window are all removed.

Verified live: picker lists real windows full-width and focus-window
dispatches (foot focused, module collapsed and re-measured); layout
menu renders with working in-place toggle. Known wart (documented in
CLAUDE.md): strip-band clicks over a NEIGHBOR segment route to the
neighbor instead of the raised expanded surface and don't close the
menu; scene-based hit-test says the raise should win — unresolved.

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

 CLAUDE.md    |  16 ++--
 src/cloud.rs | 106 +++---------------------
 src/main.rs  | 260 ++++++++++++++++++++++++++++++++---------------------------
 3 files changed, 164 insertions(+), 218 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ad01d43..d7f088e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -102,12 +102,16 @@ DBusMenu "clicked" via `send_tray_menu_event`). The compositor treats a status
 segment thicker than the bar as expanded: frozen slot, no size enforcement,
 raised above overlapped windows; the bar must reset its own height on close.
 
-**The remaining popups are `cce-cloud` processes**: the window picker and the
-layout-mode menu run a `cce_ui::process::CloudPopup` (`run_json`/`run_dmenu`)
-on a worker thread, with the single-popup toggle state in
-`cce_ui::process::CloudPopupTracker` (`StatusApp.cloud_popups`) — the thread
-reports back via `CloudSpawned`/`CloudClosed`, clicking a trigger again toggles
-off, and closing restores focus with `ccectl focus-window`.
+**No cce-cloud popups remain in this app**: the window picker (window-module
+title click → `MenuReady` rows of `Ccectl(["focus-window", id])`) and the
+layout-mode menu (layout-indicator click → `SetMode` rows + the in-place
+`ToggleApplyAll` checkbox row) are in-surface menus too. Menu width sizes to
+the longest row label. KNOWN WART: while a menu is open, strip-band clicks
+(y < bar height) landing over a NEIGHBORING segment's box route to the
+neighbor instead of the raised expanded surface, so they don't close the
+menu — close via the menu, its padding, or the module's own strip. The
+compositor hit-test (`Scene::at`, wlr_scene_node_at) is scene-based, so why
+the raise loses there is unresolved.
 
 ## Config
 
diff --git a/src/cloud.rs b/src/cloud.rs
index 5543da2..f7f2766 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -1,10 +1,9 @@
-//! Menu machinery: the in-surface tray/module menu model (fetched DBusMenu
-//! layouts flattened into pages of plain-data rows that ride a CustomEvent
-//! into the module's update loop), plus the remaining cce-cloud popup (the
-//! window picker).
+//! Menu machinery: the in-surface menu model — fetched DBusMenu layouts and
+//! bar-built menus alike are flattened into pages of plain-data rows that
+//! ride a CustomEvent into the module's update loop, where the module's own
+//! surface expands to show them.
 
-use crate::{parse_ccectl_windows, CustomEvent};
-use crate::config::get_ccectl_cmd;
+use crate::CustomEvent;
 
 #[zbus::proxy(
     interface = "com.canonical.dbusmenu",
@@ -112,6 +111,13 @@ pub(crate) enum MenuRowAction {
     Back(usize),
     /// Dispatch a bar-internal event (the module context menu's rows).
     Dispatch(CustomEvent),
+    /// Run ccectl with these args, detached (window picker rows).
+    Ccectl(Vec<String>),
+    /// Apply a window mode (the layout menu): honors the menu's live
+    /// apply-to-all toggle and captured viewport at click time.
+    SetMode(String),
+    /// Flip the layout menu's apply-to-all toggle in place (stays open).
+    ToggleApplyAll,
     /// Non-interactive (separators).
     Inert,
 }
@@ -246,91 +252,3 @@ pub(crate) fn send_tray_menu_event(destination: String, menu_path: String, id: i
         });
     });
 }
-
-pub(crate) fn get_currently_focused_window() -> Option<String> {
-    let out = std::process::Command::new(get_ccectl_cmd())
-        .args(["windows", "--json"])
-        .output()
-        .ok()?;
-    let stdout_str = String::from_utf8_lossy(&out.stdout);
-    stdout_str
-        .lines()
-        .filter_map(crate::parse_ccectl_window_any_line)
-        .find(|(_, app_id, _, focused)| {
-            *focused && app_id != "cce-status" && app_id != "cce-cloud"
-        })
-        .map(|(id, _, _, _)| id)
-}
-
-
-/// Spawn the click-to-pick window list as a cce-cloud dmenu process, report
-/// lifecycle via CloudSpawned/CloudClosed, and focus the picked window.
-pub(crate) fn spawn_window_picker(
-    x_pos: i32,
-    y_pos: i32,
-    thread_sender: calloop::channel::Sender<CustomEvent>,
-    source: String,
-) {
-    std::thread::spawn(move || {
-        // Fetch the windows list; an older compositor ignores --json and
-        // answers in the text format, which parse_ccectl_windows detects.
-        let output = std::process::Command::new(get_ccectl_cmd())
-            .args(["windows", "--json"])
-            .output();
-
-        let windows = if let Ok(out) = output {
-            parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
-        } else {
-            Vec::new()
-        };
-
-        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: source.clone() });
-            return;
-        }
-
-        // Format items for dmenu, keeping the stable order returned by ccectl
-        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 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() });
-        });
-
-        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: spawned_pid, source: source.clone() });
-    });
-}
diff --git a/src/main.rs b/src/main.rs
index 993e923..bf5d3b0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -116,8 +116,8 @@ pub(crate) enum CustomEvent {
     SystemStatsUpdated(SystemStats),
     TrayUpdated(TrayItem),
     TrayRemoved(String),
-    CloudSpawned { pid: u32, source: String },
-    CloudClosed { pid: u32, source: String },
+    /// A bar-built in-surface menu (window picker), fetched off-thread.
+    MenuReady { title: String, pages: Vec<MenuPage>, min_w: f32 },
     SwitcherTriggered,
     /// A tray icon's DBusMenu, fetched and flattened for the in-surface menu.
     TrayMenuFetched { destination: String, menu_path: String, pages: Vec<MenuPage> },
@@ -138,6 +138,10 @@ struct ModuleContextMenu {
     /// to; None for the bar's own module menu.
     tray_target: Option<(String, String)>,
     min_w: f32,
+    /// The layout menu's "apply to all sharing mode" toggle.
+    apply_all: bool,
+    /// Viewport captured when the layout menu opened (SetMode target).
+    active_viewport: i32,
     hovered: Option<usize>,
     /// Menu box in surface-local logical coords, set by `rebuild_layout`.
     rect: (f32, f32, f32, f32),
@@ -296,8 +300,6 @@ struct StatusApp {
     tray_item_bounds: Vec<TrayIconBounds>,
     viewport_bounds: Vec<ViewportBounds>,
     layout_bounds: Option<LayoutBounds>,
-    cloud_popups: cce_ui::process::CloudPopupTracker,
-    previously_focused_window: Option<String>,
 
     font_system: FontSystem,
     status_bar: cce_ui::widget::Adapted<cce_ui::widget::StatusBar>,
@@ -691,7 +693,17 @@ impl StatusApp {
                 // applies) with rows, separators and a hover highlight on top.
                 if let Some(menu) = &mut self.context_menu {
                     let module_w = self.width as f32;
-                    let menu_w = module_w.max(menu.min_w);
+                    // Wide enough for the longest row label — fixed minimums
+                    // truncated window titles in the picker.
+                    let tx_probe = ModuleContextMenu::PAD + 8.0;
+                    let mut label_w: f32 = 0.0;
+                    for page_row in menu.rows().to_vec() {
+                        if !page_row.separator {
+                            let l = cce_ui::widget::StyledLabel::new_with_family(&mut self.font_system, &page_row.label, font_size, [0.0, 0.0, 0.0, 1.0], &font_family);
+                            label_w = label_w.max(l.w);
+                        }
+                    }
+                    let menu_w = module_w.max(menu.min_w).max(label_w + 2.0 * tx_probe);
                     let menu_h = menu.height();
                     menu.rect = (0.0, bar_h, menu_w, menu_h);
                     self.width = self.width.max(menu_w.round() as u32);
@@ -842,30 +854,46 @@ impl StatusApp {
             return;
         }
 
-        // Otherwise this is a click on the status-bar "window" module: show a
-        // click-to-pick list of the current windows.
-        let switcher_source = "window".to_string();
-
-        if self.cloud_popups.click(&switcher_source) == cce_ui::process::CloudPopupClick::ToggledOff {
-            return;
-        }
-
-        // 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 {
-            if mb.name == "window" {
-                target_x = mb.x;
-                break;
+        // Otherwise this is a click on the status-bar "window" module: open
+        // the click-to-pick window list as an IN-SURFACE menu (the window
+        // module's own surface expands below the strip).
+        let thread_sender = self.sender.clone();
+        std::thread::spawn(move || {
+            let output = std::process::Command::new(get_ccectl_cmd())
+                .args(["windows", "--json"])
+                .output();
+            let windows = if let Ok(out) = output {
+                parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
+            } else {
+                Vec::new()
+            };
+            if windows.is_empty() {
+                return;
             }
-        }
-
-        let bar_height = read_status_height_from_config() as i32;
-
-        // Position it under Window module
-        let x_pos = target_x as i32;
-        let y_pos = bar_height;
-
-        cloud::spawn_window_picker(x_pos, y_pos, self.sender.clone(), switcher_source);
+            let rows = windows
+                .into_iter()
+                // The bar's own segments are noise in a window picker.
+                .filter(|(_, app_id, _, _)| !app_id.starts_with("cce-status"))
+                .map(|(id, app_id, title, _)| {
+                    let display = if title.is_empty() {
+                        app_id.clone()
+                    } else {
+                        format!("{} ({})", title, app_id)
+                    };
+                    MenuRow {
+                        label: display,
+                        enabled: true,
+                        separator: false,
+                        action: MenuRowAction::Ccectl(vec!["focus-window".to_string(), id]),
+                    }
+                })
+                .collect();
+            let _ = thread_sender.send(CustomEvent::MenuReady {
+                title: "Windows".to_string(),
+                pages: vec![MenuPage { title: "Windows".to_string(), rows }],
+                min_w: 260.0,
+            });
+        });
     }
 }
 
@@ -1140,8 +1168,6 @@ impl cce_ui::engine::Application for StatusApp {
             tray_item_bounds: Vec::new(),
             viewport_bounds: Vec::new(),
             layout_bounds: None,
-            cloud_popups: cce_ui::process::CloudPopupTracker::new(),
-            previously_focused_window: None,
             font_system,
             status_bar: cce_ui::widget::StatusBar::new(),
             rects: Vec::new(),
@@ -1232,26 +1258,22 @@ impl cce_ui::engine::Application for StatusApp {
             CustomEvent::TrayRemoved(id) => {
                 self.tray_items.remove(&id);
             }
-            CustomEvent::CloudSpawned { pid, source } => {
-                self.cloud_popups.on_spawned(pid, &source);
-            }
-            CustomEvent::CloudClosed { pid, source } => {
-                if self.cloud_popups.on_closed(pid, &source) {
-                    log::debug!("[cloud-event] CloudClosed: pid {} for source {} closed, clearing tracking", pid, source);
-                    if source == "window" {
-                        self.previously_focused_window = None;
-                    } else if source == "layout" {
-                        if let Some(ref focus_query) = self.previously_focused_window {
-                            log::debug!("[cloud-event] Restoring focus to: {}", focus_query);
-                            let focus_query_clone = focus_query.clone();
-                            std::thread::spawn(move || {
-                                let _ = std::process::Command::new(get_ccectl_cmd())
-                                    .args(["focus-window", &focus_query_clone])
-                                    .status();
-                            });
-                        }
-                        self.previously_focused_window = None;
-                    }
+            CustomEvent::MenuReady { title, pages, min_w } => {
+                if !pages.is_empty() && !self.is_vertical() {
+                    let _ = title;
+                    self.context_menu = Some(ModuleContextMenu {
+                        pages,
+                        page: 0,
+                        tray_target: None,
+                        min_w,
+                        apply_all: false,
+                        active_viewport: 0,
+                        hovered: None,
+                        rect: (0.0, 0.0, 0.0, 0.0),
+                        row_bounds: Vec::new(),
+                    });
+                } else {
+                    changed = false;
                 }
             }
             CustomEvent::TrayMenuFetched { destination, menu_path, pages } => {
@@ -1261,6 +1283,8 @@ impl cce_ui::engine::Application for StatusApp {
                         page: 0,
                         tray_target: Some((destination, menu_path)),
                         min_w: 260.0,
+                        apply_all: false,
+                        active_viewport: 0,
                         hovered: None,
                         rect: (0.0, 0.0, 0.0, 0.0),
                         row_bounds: Vec::new(),
@@ -1465,6 +1489,40 @@ impl cce_ui::engine::Application for StatusApp {
                             menu.page = p;
                             menu.hovered = None;
                         }
+                        Some(MenuRowAction::Ccectl(args)) => {
+                            std::thread::spawn(move || {
+                                let _ = std::process::Command::new(get_ccectl_cmd())
+                                    .args(&args)
+                                    .spawn();
+                            });
+                            self.context_menu = None;
+                        }
+                        Some(MenuRowAction::SetMode(mode)) => {
+                            let args = if menu.apply_all {
+                                vec!["apply-mode-sharing".to_string(), mode]
+                            } else {
+                                vec![
+                                    "viewport-layout".to_string(),
+                                    menu.active_viewport.to_string(),
+                                    mode,
+                                ]
+                            };
+                            std::thread::spawn(move || {
+                                let _ = std::process::Command::new(get_ccectl_cmd())
+                                    .args(&args)
+                                    .spawn();
+                            });
+                            self.context_menu = None;
+                        }
+                        Some(MenuRowAction::ToggleApplyAll) => {
+                            menu.apply_all = !menu.apply_all;
+                            let mark = if menu.apply_all { "[x]" } else { "[ ]" };
+                            if let Some(page) = menu.pages.get_mut(menu.page) {
+                                if let Some(row) = page.rows.get_mut(i) {
+                                    row.label = format!("{} Apply to all sharing mode", mark);
+                                }
+                            }
+                        }
                         _ => {}
                     }
                 }
@@ -1613,6 +1671,8 @@ impl cce_ui::engine::Application for StatusApp {
                         page: 0,
                         tray_target: None,
                         min_w: 190.0,
+                        apply_all: false,
+                        active_viewport: 0,
                         hovered: None,
                         rect: (0.0, 0.0, 0.0, 0.0),
                         row_bounds: Vec::new(),
@@ -1636,75 +1696,39 @@ impl cce_ui::engine::Application for StatusApp {
                 }
 
                 if clicked_layout {
-                    log::debug!("[layout-click] Layout mode clicked!");
-                    let layout_source = "layout".to_string();
-
-                    if self.cloud_popups.click(&layout_source) == cce_ui::process::CloudPopupClick::ToggledOff {
-                        return None;
-                    }
-
-                    if self.previously_focused_window.is_none() {
-                        self.previously_focused_window = get_currently_focused_window();
-                    }
-
-                    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);
-                    
-                    let layout_json = serde_json::json!({
-                        "width": 240,
-                        "height": 320,
-                        "widgets": [
-                            { "type": "label", "text": "Window Mode" },
-                            { "id": "apply_all", "type": "checkbox", "text": "Apply to all sharing mode", "checked": false },
-                            { "id": "cascade", "type": "button", "text": "Cascade" },
-                            { "id": "grid", "type": "button", "text": "Grid" },
-                            { "id": "fullscreen", "type": "button", "text": "Fullscreen" },
-                            { "id": "floating", "type": "button", "text": "Floating" },
-                            { "id": "popup", "type": "button", "text": "Popup" }
-                        ]
-                    }).to_string();
-
-                    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(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: spawned_pid, source: layout_source });
+                    log::debug!("[layout-click] opening in-surface layout menu");
+                    let mode_row = |label: &str| MenuRow {
+                        label: label.to_string(),
+                        enabled: true,
+                        separator: false,
+                        action: MenuRowAction::SetMode(label.to_lowercase()),
+                    };
+                    let rows = vec![
+                        MenuRow {
+                            label: "[ ] Apply to all sharing mode".to_string(),
+                            enabled: true,
+                            separator: false,
+                            action: MenuRowAction::ToggleApplyAll,
+                        },
+                        mode_row("Cascade"),
+                        mode_row("Grid"),
+                        mode_row("Fullscreen"),
+                        mode_row("Floating"),
+                        mode_row("Popup"),
+                    ];
+                    self.context_menu = Some(ModuleContextMenu {
+                        pages: vec![MenuPage { title: "Window Mode".to_string(), rows }],
+                        page: 0,
+                        tray_target: None,
+                        min_w: 240.0,
+                        apply_all: false,
+                        active_viewport: get_active_viewport_from_camera(&self.viewport) as i32,
+                        hovered: None,
+                        rect: (0.0, 0.0, 0.0, 0.0),
+                        row_bounds: Vec::new(),
                     });
+                    self.needs_rebuild = true;
+                    *needs_rebuild = true;
                 } else {
                     let mut clicked_window = false;
                     for mb in &self.module_bounds {