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

commita217f4b0d9e1befeb08aaecdf997ca8b898d3add
parentf9430ab269
authorLucas Galante <[email protected]>
date2026-08-05 10:17
feat: module right-click menus render in-surface, replacing cce-cloud popups

Right-clicking a module no longer spawns a cce-cloud process: the
module's own surface expands below the bar strip and the menu (module
name header + Hide/Show Modules + Adjust Positions, or Done in adjust
mode) is drawn into the retained buffers — the panel goes through the
module box pipeline, so box_bevel and the app-config styling apply.
The compositor keeps the segment's frozen slot, stops enforcing its
size, and raises it while expanded (cce-window-manager/cce-fx@HEAD).

Interaction: hover highlights rows (pointer-move hit-testing), left
click on a row dispatches its CustomEvent through update() and closes,
any other press closes. is_vertical() is gated while the menu is open
(a tall surface must not read as a vertical bar), and menus only open
on horizontal segments for now. The CloudPopup path for tray icon
DBusMenus and the layout menu is unchanged.

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

 src/main.rs | 234 ++++++++++++++++++++++++++++++++++++++++++++----------------
 1 file changed, 173 insertions(+), 61 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 0eba142..73e84f1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -123,6 +123,48 @@ pub(crate) enum CustomEvent {
     ToggleAdjustPositionMode,
 }
 
+/// The in-surface right-click menu: instead of spawning a popup process, the
+/// module's own surface EXPANDS below the bar strip to contain the menu. The
+/// compositor treats a status segment thicker than the bar as expanded — it
+/// keeps the segment's frozen slot, stops enforcing its size, and raises it
+/// above the windows the menu overlaps.
+struct ModuleContextMenu {
+    module: String,
+    /// (label, action sent through `update` when clicked)
+    items: Vec<(String, CustomEvent)>,
+    hovered: Option<usize>,
+    /// Menu box in surface-local logical coords, set by `rebuild_layout`.
+    rect: (f32, f32, f32, f32),
+}
+
+impl ModuleContextMenu {
+    const PAD: f32 = 6.0;
+    const HEADER_H: f32 = 26.0;
+    const ITEM_H: f32 = 28.0;
+    const MIN_W: f32 = 190.0;
+
+    fn height(&self) -> f32 {
+        2.0 * Self::PAD + Self::HEADER_H + self.items.len() as f32 * Self::ITEM_H
+    }
+
+    fn contains(&self, x: f32, y: f32) -> bool {
+        let (mx, my, mw, mh) = self.rect;
+        x >= mx && x <= mx + mw && y >= my && y <= my + mh
+    }
+
+    fn item_at(&self, x: f32, y: f32) -> Option<usize> {
+        if !self.contains(x, y) {
+            return None;
+        }
+        let rel = y - (self.rect.1 + Self::PAD + Self::HEADER_H);
+        if rel < 0.0 {
+            return None;
+        }
+        let idx = (rel / Self::ITEM_H) as usize;
+        (idx < self.items.len()).then_some(idx)
+    }
+}
+
 pub(crate) fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font_family: &str) -> Buffer {
     let scale = cce_ui::scale::scale_factor();
     let mut font_size = size;
@@ -245,6 +287,7 @@ struct StatusApp {
     current_bg_color: [f32; 4],
     box_bevel: Option<StatusBoxBevel>,
     box_bevel_depth: f32,
+    context_menu: Option<ModuleContextMenu>,
     input_regions: Vec<(i32, i32, i32, i32)>,
     module_bounds: Vec<ModuleBounds>,
     left_modules: Vec<Box<dyn StatusModule>>,
@@ -278,6 +321,12 @@ impl StatusApp {
     }
 
     fn is_vertical(&self) -> bool {
+        // An open in-surface menu makes the surface taller than wide; that
+        // must not read as a vertical bar (menus only open on horizontal
+        // segments).
+        if self.context_menu.is_some() {
+            return false;
+        }
         let bar_thickness = read_status_height_from_config() as u32;
         if self.width == bar_thickness && self.height != bar_thickness {
             true
@@ -599,6 +648,80 @@ impl StatusApp {
                 log::debug!("[module-{}] rebuild_layout horizontal: width calculated as {} (was {})", self.selected_module_name.as_deref().unwrap_or("none"), self.width, old_w);
                 self.input_regions.clear();
                 self.input_regions.push((0, 0, self.width as i32, bar_h.round() as i32));
+
+                // In-surface context menu: grow the surface below the bar
+                // strip and draw the menu into the retained buffers. The
+                // panel reuses the module box pipeline (so box_bevel applies)
+                // with text rows 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(ModuleContextMenu::MIN_W);
+                    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);
+                    self.height = (bar_h + menu_h).round() as u32;
+
+                    self.rounded_boxes.push(RoundedBox {
+                        x: 0.0,
+                        y: bar_h,
+                        w: menu_w,
+                        h: menu_h,
+                        radius: status_box_radius.max(4.0),
+                        color: [0.055, 0.055, 0.075, 0.97],
+                        corners: (false, false, true, true),
+                    });
+
+                    let text_u8 = [
+                        (normal_color[0] * 255.0) as u8,
+                        (normal_color[1] * 255.0) as u8,
+                        (normal_color[2] * 255.0) as u8,
+                    ];
+                    let dim_u8 = [
+                        (normal_color[0] * 170.0) as u8,
+                        (normal_color[1] * 170.0) as u8,
+                        (normal_color[2] * 170.0) as u8,
+                    ];
+                    let tx = ModuleContextMenu::PAD + 8.0;
+                    self.text_prims.push((
+                        menu.module.clone(),
+                        font_size,
+                        tx,
+                        bar_h + ModuleContextMenu::PAD
+                            + (ModuleContextMenu::HEADER_H - font_size) / 2.0,
+                        dim_u8,
+                        Some(font_family.clone()),
+                        None,
+                        None,
+                    ));
+                    for (i, (label, _)) in menu.items.iter().enumerate() {
+                        let iy = bar_h
+                            + ModuleContextMenu::PAD
+                            + ModuleContextMenu::HEADER_H
+                            + i as f32 * ModuleContextMenu::ITEM_H;
+                        if menu.hovered == Some(i) {
+                            self.rects.push(RectWidget {
+                                x: 2.0,
+                                y: iy,
+                                w: menu_w - 4.0,
+                                h: ModuleContextMenu::ITEM_H,
+                                color: [0.23, 0.35, 0.50, 0.55],
+                            });
+                        }
+                        self.text_prims.push((
+                            label.clone(),
+                            font_size,
+                            tx,
+                            iy + (ModuleContextMenu::ITEM_H - font_size) / 2.0,
+                            text_u8,
+                            Some(font_family.clone()),
+                            None,
+                            None,
+                        ));
+                    }
+
+                    self.input_regions.clear();
+                    self.input_regions.push((0, 0, self.width as i32, self.height as i32));
+                }
             }
         }
 
@@ -976,6 +1099,7 @@ impl cce_ui::engine::Application for StatusApp {
             current_bg_color: color::STATUS_BG,
             box_bevel: None,
             box_bevel_depth: 3.0,
+            context_menu: None,
             input_regions: Vec::new(),
             module_bounds: Vec::new(),
             left_modules,
@@ -1060,7 +1184,7 @@ impl cce_ui::engine::Application for StatusApp {
                     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" || source.starts_with("context_menu:") || source.starts_with("tray:") {
+                    } else if source == "layout" || source.starts_with("tray:") {
                         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();
@@ -1205,6 +1329,14 @@ impl cce_ui::engine::Application for StatusApp {
     }
 
     fn handle_pointer_move(&mut self, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) {
+        if let Some(menu) = &mut self.context_menu {
+            let hovered = menu.item_at(pos.x, pos.y);
+            if hovered != menu.hovered {
+                menu.hovered = hovered;
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
         let (lx, ly) = (pos.x, pos.y);
         self.cursor_pos = (lx as f64, ly as f64);
 
@@ -1223,13 +1355,31 @@ impl cce_ui::engine::Application for StatusApp {
         }
     }
 
-    fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: cce_ui::engine::LogicalPosition, _needs_rebuild: &mut bool) -> Option<Self::Message> {
+    fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
         let (lx, ly) = (pos.x, pos.y);
         let is_vertical = self.is_vertical();
         let coord = if is_vertical { ly } else { lx };
         let cx = lx as f64;
         let cy = ly as f64;
 
+        // An open in-surface menu owns every button event: item clicks
+        // dispatch their action and close; any other press (bar strip,
+        // menu padding, right-click) just closes.
+        if let Some(menu) = &self.context_menu {
+            if state != ElementState::Pressed {
+                return None;
+            }
+            let action = if button == MouseButton::Left {
+                menu.item_at(lx, ly).map(|i| menu.items[i].1.clone())
+            } else {
+                None
+            };
+            self.context_menu = None;
+            self.needs_rebuild = true;
+            *needs_rebuild = true;
+            return action;
+        }
+
         if state == ElementState::Pressed {
             // Check if tray icon was clicked
             let mut clicked_tray = None;
@@ -1350,70 +1500,32 @@ impl cce_ui::engine::Application for StatusApp {
                 }
 
                 if let Some(mb) = clicked_module {
-                    log::debug!("[module-right-click] Right-clicked module: {}", mb.name);
-                    let context_source = format!("context_menu:{}", mb.name);
-
-                    if self.cloud_popups.click(&context_source) == cce_ui::process::CloudPopupClick::ToggledOff {
+                    if is_vertical {
+                        // v1: the in-surface menu only lays out on horizontal
+                        // segments.
                         return None;
                     }
-
-                    if self.previously_focused_window.is_none() {
-                        self.previously_focused_window = get_currently_focused_window();
-                    }
-
-                    let x_pos = mb.x as i32;
-                    let y_pos = read_status_height_from_config() as i32;
-                    let context_json = if self.adjust_position_mode {
-                        serde_json::json!({
-                            "width": 180,
-                            "height": 80,
-                            "widgets": [
-                                { "type": "label", "text": mb.name },
-                                { "type": "button", "text": "Done", "id": "toggle_adjust" }
-                            ]
-                        }).to_string()
+                    log::debug!("[module-right-click] opening in-surface menu for: {}", mb.name);
+                    let items = if self.adjust_position_mode {
+                        vec![("Done".to_string(), CustomEvent::ToggleAdjustPositionMode)]
                     } else {
-                        let menu_text = if self.status_hide_mode {
-                            "Show Modules"
-                        } else {
-                            "Hide Modules"
-                        };
-                        serde_json::json!({
-                            "width": 180,
-                            "height": 110,
-                            "widgets": [
-                                { "type": "label", "text": mb.name },
-                                { "type": "button", "text": menu_text, "id": "toggle_hide" },
-                                { "type": "button", "text": "Adjust Positions", "id": "toggle_adjust" }
-                            ]
-                        }).to_string()
+                        vec![
+                            (
+                                if self.status_hide_mode { "Show Modules" } else { "Hide Modules" }
+                                    .to_string(),
+                                CustomEvent::ToggleHideModules,
+                            ),
+                            ("Adjust Positions".to_string(), CustomEvent::ToggleAdjustPositionMode),
+                        ]
                     };
-
-                    let parent_app_id = self.get_app_id();
-                    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: spawned_pid, source: context_source });
+                    self.context_menu = Some(ModuleContextMenu {
+                        module: mb.name.clone(),
+                        items,
+                        hovered: None,
+                        rect: (0.0, 0.0, 0.0, 0.0),
                     });
-
+                    self.needs_rebuild = true;
+                    *needs_rebuild = true;
                     return None;
                 }
             }