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

commitb13eefd51100991082bfedc1a823997d2f0b29f0
parentd886423068
authorLucas Galante <[email protected]>
date2026-08-05 10:35
feat: tray icon DBusMenus render in-surface too

The tray segment's surface now expands to show a right-clicked icon's
DBusMenu, replacing the cce-cloud popup: the click thread fetches the
menu (fetch_tray_menu_pages — about_to_show + GetLayout + the existing
parse, flattened into MenuPage/MenuRow pages) and hands it to the
module's update loop via CustomEvent::TrayMenuFetched. Submenus
paginate in place (Submenu/Back rows re-lay the same surface),
separators render as divider lines, disabled rows dim and don't
hit-test, toggle states keep their [x]/[ ] prefixes, and row clicks
fire the DBusMenu "clicked" event from a detached thread
(send_tray_menu_event).

ModuleContextMenu generalizes to pages of MenuRows with per-row bounds
(mixed heights), and the module context menu becomes a one-page
instance of the same model. The dead cce-cloud tray path is removed
(show_cce_cloud_menu, the tray popup tracker gate, focus juggling);
the icon tooltip is suppressed while a menu is open (it overlapped the
header). Verified live: Claude's tray menu with separator, Show App
dispatch, collapse to strip size, zero configures throughout.

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

 CLAUDE.md    |  23 ++--
 src/cloud.rs | 375 ++++++++++++++++++++++++-----------------------------------
 src/main.rs  | 301 ++++++++++++++++++++++++++++++-----------------
 3 files changed, 363 insertions(+), 336 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7c4fbfd..ad01d43 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,14 +93,21 @@ as a parse fallback for older compositors (`parse_ccectl_window_any_line` handle
 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 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.
+**Right-click menus are IN-SURFACE** (`ModuleContextMenu`): the module's own
+surface expands below the bar strip to contain the menu — module context menus
+and tray icon DBusMenus alike (fetched/flattened by `cloud.rs::
+fetch_tray_menu_pages` into `MenuPage`/`MenuRow` pages riding a
+`CustomEvent::TrayMenuFetched`; submenus paginate in place; row clicks send the
+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`.
 
 ## Config
 
diff --git a/src/cloud.rs b/src/cloud.rs
index c3b7c0a..5543da2 100644
--- a/src/cloud.rs
+++ b/src/cloud.rs
@@ -1,5 +1,7 @@
-//! cce-cloud popups: the window picker and D-Bus menus rendered by spawning
-//! a `cce-cloud` process fed JSON pages on stdin.
+//! 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).
 
 use crate::{parse_ccectl_windows, CustomEvent};
 use crate::config::get_ccectl_cmd;
@@ -90,241 +92,174 @@ pub(crate) fn parse_menu_item(
     })
 }
 
-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)
+/// One row of an in-surface menu — plain data so a fetched DBusMenu can ride
+/// a `CustomEvent` into the module process's update loop.
+#[derive(Debug, Clone)]
+pub(crate) struct MenuRow {
+    pub label: String,
+    pub enabled: bool,
+    pub separator: bool,
+    pub action: MenuRowAction,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) enum MenuRowAction {
+    /// DBusMenu item: send "clicked" to the menu's owner on click.
+    Item(i32),
+    /// Navigate to a submenu page (in-surface pagination).
+    Submenu(usize),
+    /// Navigate back to the parent page.
+    Back(usize),
+    /// Dispatch a bar-internal event (the module context menu's rows).
+    Dispatch(CustomEvent),
+    /// Non-interactive (separators).
+    Inert,
 }
 
+#[derive(Debug, Clone)]
+pub(crate) struct MenuPage {
+    pub title: String,
+    pub rows: Vec<MenuRow>,
+}
 
-pub(crate) async fn show_cce_cloud_menu(
+/// Fetch a tray icon's DBusMenu and flatten it into in-surface pages: page 0
+/// is the root; each enabled submenu becomes its own page (capped at 16)
+/// reached by a `Submenu` row and left by the "< Back" row. Separators and
+/// disabled items are kept as rows for visual fidelity; toggle states become
+/// `[x]`/`[ ]` label prefixes, exactly like the popup renderer they replace.
+pub(crate) async fn fetch_tray_menu_pages(
     conn: &zbus::Connection,
     destination: &str,
     menu_path: &str,
-    x_pos: i32,
-    y_pos: i32,
-    align_right: bool,
-    thread_sender: calloop::channel::Sender<CustomEvent>,
-    source: String,
-    parent_app_id: String,
-) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
-    let mut last_spawned_pid = 0;
-
-    let res = async {
-        let menu_proxy = DBusMenuProxy::builder(conn)
-            .destination(destination)?
-            .path(menu_path)?
-            .build()
-            .await?;
-
-        let _ = menu_proxy.about_to_show(0).await;
-        let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
-
-        let root_item = match parse_menu_item(layout.0, layout.1, layout.2) {
-            Some(item) => item,
-            None => return Ok(()),
-        };
-
-        // Assign page indices to submenus.
-        let mut page_indices = std::collections::HashMap::new();
-        page_indices.insert(root_item.id, 0);
-        let mut parent_pages = std::collections::HashMap::new();
-        let mut next_page = 1;
-
-        fn assign_pages(
-            item: &MenuItem,
-            current_page: usize,
-            page_indices: &mut std::collections::HashMap<i32, usize>,
-            parent_pages: &mut std::collections::HashMap<usize, usize>,
-            next_page: &mut usize,
-        ) {
-            for child in &item.children {
-                if child.is_separator || !child.enabled {
-                    continue;
-                }
-                if !child.children.is_empty() && *next_page < 16 {
-                    let child_page = *next_page;
-                    page_indices.insert(child.id, child_page);
-                    parent_pages.insert(child_page, current_page);
-                    *next_page += 1;
-                    assign_pages(child, child_page, page_indices, parent_pages, next_page);
-                }
-            }
+) -> Result<Vec<MenuPage>, Box<dyn std::error::Error + Send + Sync>> {
+    let menu_proxy = DBusMenuProxy::builder(conn)
+        .destination(destination)?
+        .path(menu_path)?
+        .build()
+        .await?;
+
+    let _ = menu_proxy.about_to_show(0).await;
+    let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
+    let root = match parse_menu_item(layout.0, layout.1, layout.2) {
+        Some(item) => item,
+        None => return Ok(Vec::new()),
+    };
+
+    fn build(
+        item: &MenuItem,
+        page: usize,
+        parent: Option<usize>,
+        pages: &mut Vec<MenuPage>,
+    ) {
+        let mut rows = Vec::new();
+        if let Some(parent_page) = parent {
+            rows.push(MenuRow {
+                label: "< Back".to_string(),
+                enabled: true,
+                separator: false,
+                action: MenuRowAction::Back(parent_page),
+            });
         }
-
-        assign_pages(&root_item, 0, &mut page_indices, &mut parent_pages, &mut next_page);
-
-        #[derive(Debug, Clone)]
-        struct LocalWidget {
-            widget_type: String,
-            text: String,
-            id: Option<String>,
-            target_page: Option<usize>,
-        }
-
-        #[derive(Debug, Clone)]
-        struct LocalPage {
-            title: String,
-            widgets: Vec<LocalWidget>,
-        }
-
-        let mut pages = vec![LocalPage {
-            title: "".to_string(),
-            widgets: Vec::new(),
-        }; next_page];
-
-        fn build_pages(
-            item: &MenuItem,
-            current_page: usize,
-            page_indices: &std::collections::HashMap<i32, usize>,
-            parent_pages: &std::collections::HashMap<usize, usize>,
-            pages: &mut [LocalPage],
-        ) {
-            let mut widgets = Vec::new();
-
-            if current_page > 0 {
-                if let Some(&parent_page) = parent_pages.get(&current_page) {
-                    widgets.push(LocalWidget {
-                        widget_type: "button".to_string(),
-                        text: "< Back".to_string(),
-                        id: Some(format!("back_to_{}", parent_page)),
-                        target_page: Some(parent_page),
-                    });
-                }
-            }
-
-            for child in &item.children {
-                if child.is_separator || !child.enabled {
-                    continue;
-                }
-
-                let mut display_label = if child.toggle_state == 1 {
-                    format!("[x] {}", child.label)
-                } else if child.toggle_state == 0 {
-                    format!("[ ] {}", child.label)
-                } else {
-                    child.label.clone()
-                };
-
-                if !child.children.is_empty() {
-                    if let Some(&target_page) = page_indices.get(&child.id) {
-                        display_label = format!("{} >", display_label);
-
-                        widgets.push(LocalWidget {
-                            widget_type: "button".to_string(),
-                            text: display_label,
-                            id: Some(format!("submenu_{}", child.id)),
-                            target_page: Some(target_page),
-                        });
-
-                        build_pages(child, target_page, page_indices, parent_pages, pages);
-                    } else {
-                        widgets.push(LocalWidget {
-                            widget_type: "button".to_string(),
-                            text: display_label,
-                            id: Some(format!("item_{}", child.id)),
-                            target_page: None,
-                        });
-                    }
-                } else {
-                    widgets.push(LocalWidget {
-                        widget_type: "button".to_string(),
-                        text: display_label,
-                        id: Some(format!("item_{}", child.id)),
-                        target_page: None,
-                    });
-                }
+        // Reserve this page's slot before recursing so child pages number
+        // depth-first after it.
+        pages[page].title = if item.label.is_empty() && page == 0 {
+            "Tray Menu".to_string()
+        } else {
+            item.label.clone()
+        };
+        for child in &item.children {
+            if child.is_separator {
+                rows.push(MenuRow {
+                    label: String::new(),
+                    enabled: false,
+                    separator: true,
+                    action: MenuRowAction::Inert,
+                });
+                continue;
             }
-
-            let title = if item.label.is_empty() {
-                if current_page == 0 {
-                    "Tray Menu".to_string()
-                } else {
-                    "".to_string()
-                }
+            let mut label = if child.toggle_state == 1 {
+                format!("[x] {}", child.label)
+            } else if child.toggle_state == 0 {
+                format!("[ ] {}", child.label)
             } else {
-                item.label.clone()
-            };
-
-            pages[current_page] = LocalPage {
-                title,
-                widgets,
+                child.label.clone()
             };
-        }
-
-        build_pages(&root_item, 0, &page_indices, &parent_pages, &mut pages);
-
-        // Serialize to JSON value
-        let mut pages_json = Vec::new();
-        for page in pages {
-            let mut widgets_json = Vec::new();
-            for w in page.widgets {
-                let mut w_val = serde_json::json!({
-                    "type": w.widget_type,
-                    "text": w.text,
+            if !child.children.is_empty() && child.enabled && pages.len() < 16 {
+                label = format!("{} >", label);
+                let child_page = pages.len();
+                pages.push(MenuPage { title: String::new(), rows: Vec::new() });
+                rows.push(MenuRow {
+                    label,
+                    enabled: true,
+                    separator: false,
+                    action: MenuRowAction::Submenu(child_page),
+                });
+                build(child, child_page, Some(page), pages);
+            } else {
+                rows.push(MenuRow {
+                    label,
+                    enabled: child.enabled,
+                    separator: false,
+                    action: MenuRowAction::Item(child.id),
                 });
-                if let Some(id) = w.id {
-                    w_val["id"] = serde_json::Value::String(id);
-                }
-                if let Some(tp) = w.target_page {
-                    w_val["target_page"] = serde_json::Value::Number(tp.into());
-                }
-                widgets_json.push(w_val);
             }
-            pages_json.push(serde_json::json!({
-                "title": page.title,
-                "widgets": widgets_json,
-            }));
         }
+        pages[page].rows = rows;
+    }
 
-        let layout_json = serde_json::json!({
-            "width": 260,
-            "pages": pages_json,
-        });
-        let layout_str = layout_json.to_string();
-
-        let mut popup = cce_ui::process::CloudPopup::at(x_pos, y_pos)
-            .parent_app_id(parent_app_id);
-        if align_right {
-            popup = popup.align_right();
-        }
-        // 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() });
-        })?;
+    let mut pages = vec![MenuPage { title: String::new(), rows: Vec::new() }];
+    build(&root, 0, None, &mut pages);
+    if pages[0].rows.is_empty() {
+        return Ok(Vec::new());
+    }
+    Ok(pages)
+}
 
-        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>() {
-                            let timestamp = std::time::SystemTime::now()
-                                .duration_since(std::time::UNIX_EPOCH)
-                                .unwrap_or_default()
-                                .as_secs() as u32;
-                            let val = zbus::zvariant::Value::from("");
-                            let _ = menu_proxy.event(item_id, "clicked", &val, timestamp).await;
-                        }
-                    }
-                }
+/// Fire a DBusMenu "clicked" event for an in-surface menu row, detached —
+/// the click handler must not block on D-Bus.
+pub(crate) fn send_tray_menu_event(destination: String, menu_path: String, id: i32) {
+    std::thread::spawn(move || {
+        let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
+            Ok(rt) => rt,
+            Err(_) => return,
+        };
+        rt.block_on(async move {
+            let res: Result<(), Box<dyn std::error::Error + Send + Sync>> = async {
+                let conn = zbus::Connection::session().await?;
+                let proxy = DBusMenuProxy::builder(&conn)
+                    .destination(destination.as_str())?
+                    .path(menu_path.as_str())?
+                    .build()
+                    .await?;
+                let timestamp = std::time::SystemTime::now()
+                    .duration_since(std::time::UNIX_EPOCH)
+                    .unwrap_or_default()
+                    .as_secs() as u32;
+                let val = zbus::zvariant::Value::from("");
+                proxy.event(id, "clicked", &val, timestamp).await?;
+                Ok(())
             }
-        }
-        Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
-    }.await;
+            .await;
+            if let Err(e) = res {
+                log::warn!("[tray-menu] clicked event failed: {:?}", e);
+            }
+        });
+    });
+}
 
-    let _ = thread_sender.send(CustomEvent::CloudClosed { pid: last_spawned_pid, source });
-    res
+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)
 }
 
 
diff --git a/src/main.rs b/src/main.rs
index 6f3d990..993e923 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -119,6 +119,8 @@ pub(crate) enum CustomEvent {
     CloudSpawned { pid: u32, source: String },
     CloudClosed { pid: u32, source: String },
     SwitcherTriggered,
+    /// A tray icon's DBusMenu, fetched and flattened for the in-surface menu.
+    TrayMenuFetched { destination: String, menu_path: String, pages: Vec<MenuPage> },
     ToggleHideModules,
     ToggleAdjustPositionMode,
 }
@@ -127,24 +129,44 @@ pub(crate) enum CustomEvent {
 /// 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.
+/// above the windows the menu overlaps. Pages support DBusMenu submenus:
+/// tray icon menus navigate in place (`Submenu`/`Back` rows).
 struct ModuleContextMenu {
-    module: String,
-    /// (label, action sent through `update` when clicked)
-    items: Vec<(String, CustomEvent)>,
+    pages: Vec<MenuPage>,
+    page: usize,
+    /// (destination, menu_path) — the DBusMenu owner `Item` rows dispatch
+    /// to; None for the bar's own module menu.
+    tray_target: Option<(String, String)>,
+    min_w: f32,
     hovered: Option<usize>,
     /// Menu box in surface-local logical coords, set by `rebuild_layout`.
     rect: (f32, f32, f32, f32),
+    /// Per-row (y offset from the menu top, height), parallel to the current
+    /// page's rows; rebuilt with the layout (rows have mixed heights).
+    row_bounds: Vec<(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;
+    const SEP_H: f32 = 9.0;
+
+    fn rows(&self) -> &[MenuRow] {
+        self.pages.get(self.page).map(|p| p.rows.as_slice()).unwrap_or(&[])
+    }
+
+    fn title(&self) -> &str {
+        self.pages.get(self.page).map(|p| p.title.as_str()).unwrap_or("")
+    }
 
     fn height(&self) -> f32 {
-        2.0 * Self::PAD + Self::HEADER_H + self.items.len() as f32 * Self::ITEM_H
+        let rows: f32 = self
+            .rows()
+            .iter()
+            .map(|r| if r.separator { Self::SEP_H } else { Self::ITEM_H })
+            .sum();
+        2.0 * Self::PAD + Self::HEADER_H + rows
     }
 
     fn contains(&self, x: f32, y: f32) -> bool {
@@ -152,16 +174,21 @@ impl ModuleContextMenu {
         x >= mx && x <= mx + mw && y >= my && y <= my + mh
     }
 
+    /// The interactive row under the pointer (separators, disabled and inert
+    /// rows never match).
     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)
+        let rel = y - self.rect.1;
+        self.row_bounds
+            .iter()
+            .position(|&(off, h)| rel >= off && rel < off + h)
+            .filter(|&i| {
+                self.rows().get(i).is_some_and(|r| {
+                    !r.separator && r.enabled && !matches!(r.action, MenuRowAction::Inert)
+                })
+            })
     }
 }
 
@@ -543,8 +570,9 @@ impl StatusApp {
             }
         }
 
-        // 5c. Tooltip Rendering (if hovered)
-        if let Some(ref hovered_id) = self.hovered_tray_item {
+        // 5c. Tooltip Rendering (if hovered) — suppressed while the
+        // in-surface menu is open (the tooltip would overlap the menu header).
+        if let Some(ref hovered_id) = self.hovered_tray_item.clone().filter(|_| self.context_menu.is_none()) {
             if let Some(bound) = self.tray_item_bounds.iter().find(|b| &b.id == hovered_id) {
                 let clean_tooltip = |title: Option<&str>, dbus_id: Option<&str>, fallback_id: &str| -> String {
                     if let Some(t) = title {
@@ -658,12 +686,12 @@ impl StatusApp {
                 }
 
                 // 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.
+                // strip and draw the current page into the retained buffers.
+                // The panel reuses the module box pipeline (so box_bevel
+                // 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(ModuleContextMenu::MIN_W);
+                    let menu_w = module_w.max(menu.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);
@@ -685,13 +713,13 @@ impl StatusApp {
                         (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,
+                        (normal_color[0] * 150.0) as u8,
+                        (normal_color[1] * 150.0) as u8,
+                        (normal_color[2] * 150.0) as u8,
                     ];
                     let tx = ModuleContextMenu::PAD + 8.0;
                     self.text_prims.push((
-                        menu.module.clone(),
+                        menu.title().to_string(),
                         font_size,
                         tx,
                         bar_h + ModuleContextMenu::PAD
@@ -701,31 +729,51 @@ impl StatusApp {
                         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) {
+
+                    let rows = menu.rows().to_vec();
+                    let hovered = menu.hovered;
+                    let mut bounds = Vec::with_capacity(rows.len());
+                    let mut off = ModuleContextMenu::PAD + ModuleContextMenu::HEADER_H;
+                    for (i, row) in rows.iter().enumerate() {
+                        let h = if row.separator {
+                            ModuleContextMenu::SEP_H
+                        } else {
+                            ModuleContextMenu::ITEM_H
+                        };
+                        let iy = bar_h + off;
+                        if row.separator {
                             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],
+                                x: tx,
+                                y: iy + h / 2.0,
+                                w: menu_w - 2.0 * tx,
+                                h: 1.0,
+                                color: [0.35, 0.35, 0.42, 0.8],
                             });
+                        } else {
+                            if hovered == Some(i) && row.enabled {
+                                self.rects.push(RectWidget {
+                                    x: 2.0,
+                                    y: iy,
+                                    w: menu_w - 4.0,
+                                    h: h,
+                                    color: [0.23, 0.35, 0.50, 0.55],
+                                });
+                            }
+                            self.text_prims.push((
+                                row.label.clone(),
+                                font_size,
+                                tx,
+                                iy + (h - font_size) / 2.0,
+                                if row.enabled { text_u8 } else { dim_u8 },
+                                Some(font_family.clone()),
+                                None,
+                                None,
+                            ));
                         }
-                        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,
-                        ));
+                        bounds.push((off, h));
+                        off += h;
                     }
+                    menu.row_bounds = bounds;
 
                     self.input_regions.clear();
                     self.input_regions.push((0, 0, self.width as i32, self.height as i32));
@@ -1192,7 +1240,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("tray:") {
+                    } 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();
@@ -1206,6 +1254,21 @@ impl cce_ui::engine::Application for StatusApp {
                     }
                 }
             }
+            CustomEvent::TrayMenuFetched { destination, menu_path, pages } => {
+                if !pages.is_empty() && !self.is_vertical() {
+                    self.context_menu = Some(ModuleContextMenu {
+                        pages,
+                        page: 0,
+                        tray_target: Some((destination, menu_path)),
+                        min_w: 260.0,
+                        hovered: None,
+                        rect: (0.0, 0.0, 0.0, 0.0),
+                        row_bounds: Vec::new(),
+                    });
+                } else {
+                    changed = false;
+                }
+            }
             CustomEvent::SwitcherTriggered => {
                 log::debug!("[switcher] SwitcherTriggered event received, calling trigger_switcher");
                 self.trigger_switcher(true);
@@ -1370,22 +1433,48 @@ impl cce_ui::engine::Application for StatusApp {
         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 {
+        // An open in-surface menu owns every button event: row clicks run
+        // their action (dispatch / DBusMenu event / page navigation); any
+        // other press (bar strip, menu padding, right-click) closes.
+        if self.context_menu.is_some() {
             if state != ElementState::Pressed {
                 return None;
             }
-            let action = if button == MouseButton::Left {
-                menu.item_at(lx, ly).map(|i| menu.items[i].1.clone())
+            let hit = if button == MouseButton::Left {
+                self.context_menu.as_ref().and_then(|m| m.item_at(lx, ly))
             } else {
                 None
             };
-            self.context_menu = None;
+            let mut result = None;
+            match hit {
+                Some(i) => {
+                    let menu = self.context_menu.as_mut().unwrap();
+                    let action = menu.rows().get(i).map(|r| r.action.clone());
+                    match action {
+                        Some(MenuRowAction::Dispatch(ev)) => {
+                            self.context_menu = None;
+                            result = Some(ev);
+                        }
+                        Some(MenuRowAction::Item(id)) => {
+                            if let Some((dest, path)) = menu.tray_target.clone() {
+                                send_tray_menu_event(dest, path, id);
+                            }
+                            self.context_menu = None;
+                        }
+                        Some(MenuRowAction::Submenu(p)) | Some(MenuRowAction::Back(p)) => {
+                            menu.page = p;
+                            menu.hovered = None;
+                        }
+                        _ => {}
+                    }
+                }
+                None => {
+                    self.context_menu = None;
+                }
+            }
             self.needs_rebuild = true;
             *needs_rebuild = true;
-            return action;
+            return result;
         }
 
         if state == ElementState::Pressed {
@@ -1401,15 +1490,6 @@ impl cce_ui::engine::Application for StatusApp {
 
             if let Some(bound) = clicked_tray {
                 let id = bound.id.clone();
-                let tray_source = format!("tray:{}", id);
-
-                if self.cloud_popups.click(&tray_source) == cce_ui::process::CloudPopupClick::ToggledOff {
-                    return None;
-                }
-                if self.previously_focused_window.is_none() {
-                    self.previously_focused_window = get_currently_focused_window();
-                }
-
                 let btn_code = match button {
                     MouseButton::Left => 272,
                     MouseButton::Right => 273,
@@ -1417,20 +1497,13 @@ impl cce_ui::engine::Application for StatusApp {
                 };
                 let cx_i = cx as i32;
                 let cy_i = cy as i32;
-                let screen_width = self.width as i32;
-                let bar_height = read_status_height_from_config() as i32;
-                let bound_x = bound.x;
-                let bound_w = bound.w;
-                let parent_app_id = self.get_app_id();
                 let thread_sender = self.sender.clone();
-                let tray_source_clone = tray_source.clone();
                 std::thread::spawn(move || {
                     let rt = tokio::runtime::Builder::new_current_thread()
                         .enable_all()
                         .build()
                         .unwrap();
                     rt.block_on(async move {
-                        let mut menu_shown = false;
                         if let Some((destination, path_part)) = id.split_once('/') {
                             let path = format!("/{}", path_part);
                             match zbus::Connection::session().await {
@@ -1450,46 +1523,50 @@ impl cce_ui::engine::Application for StatusApp {
                                             let should_show_menu = (btn_code == 273 && menu_path.is_some())
                                                 || (btn_code == 272 && is_menu && menu_path.is_some());
 
-                                            let x_pos = screen_width - (bound_x + bound_w) as i32;
-                                            let y_pos = bar_height;
-                                            log::debug!("[tray-click] Clicked tray item at bound_x={}, bound_w={}, screen_width={}, calculated x_pos={}, y_pos={}", bound_x, bound_w, screen_width, x_pos, y_pos);
-
+                                            // In-surface menu: fetch the DBusMenu layout and hand
+                                            // it to the module's update loop — the tray segment's
+                                            // own surface expands to show it (no popup process).
                                             if should_show_menu {
-                                                 if let Some(menu_p) = menu_path {
-                                                     menu_shown = true;
-                                                     if let Err(e) = show_cce_cloud_menu(&conn, destination, menu_p.as_str(), x_pos, y_pos, true, thread_sender.clone(), tray_source_clone.clone(), parent_app_id.clone()).await {
-                                                         log::warn!("[tray-click] show_cce_cloud_menu failed: {:?}", e);
-                                                     }
-                                                 }
-                                             } else if btn_code == 272 {
-                                                 if let Err(e) = proxy.activate(cx_i, cy_i).await {
-                                                     log::warn!("[tray-click] Activate failed: {:?}", e);
-                                                     if let Some(menu_p) = menu_path {
-                                                         menu_shown = true;
-                                                         if let Err(e) = show_cce_cloud_menu(&conn, destination, menu_p.as_str(), x_pos, y_pos, true, thread_sender.clone(), tray_source_clone.clone(), parent_app_id.clone()).await {
-                                                             log::warn!("[tray-click] Fallback show_cce_cloud_menu failed: {:?}", e);
-                                                         }
-                                                     }
-                                                 }
-                                             } else if btn_code == 273 {
-                                                 let _ = proxy.context_menu(cx_i, cy_i).await;
-                                             }
-                                        }
-                                        Err(e) => {
-                                            log::warn!("[tray-click] Failed to build proxy: {:?}", e);
+                                                if let Some(menu_p) = menu_path {
+                                                    match fetch_tray_menu_pages(&conn, destination, menu_p.as_str()).await {
+                                                        Ok(pages) if !pages.is_empty() => {
+                                                            let _ = thread_sender.send(CustomEvent::TrayMenuFetched {
+                                                                destination: destination.to_string(),
+                                                                menu_path: menu_p.as_str().to_string(),
+                                                                pages,
+                                                            });
+                                                        }
+                                                        Ok(_) => log::debug!("[tray-menu] empty menu for {}", destination),
+                                                        Err(e) => log::warn!("[tray-menu] fetch failed: {:?}", e),
+                                                    }
+                                                }
+                                            } else if btn_code == 272 {
+                                                if let Err(e) = proxy.activate(cx_i, cy_i).await {
+                                                    log::warn!("[tray-click] Activate failed: {:?}", e);
+                                                    if let Some(menu_p) = menu_path {
+                                                        if let Ok(pages) = fetch_tray_menu_pages(&conn, destination, menu_p.as_str()).await {
+                                                            if !pages.is_empty() {
+                                                                let _ = thread_sender.send(CustomEvent::TrayMenuFetched {
+                                                                    destination: destination.to_string(),
+                                                                    menu_path: menu_p.as_str().to_string(),
+                                                                    pages,
+                                                                });
+                                                            }
+                                                        }
+                                                    }
+                                                }
+                                            } else if btn_code == 273 {
+                                                let _ = proxy.context_menu(cx_i, cy_i).await;
+                                            }
                                         }
+                                        Err(e) => log::warn!("[tray-click] Failed to build proxy: {:?}", e),
                                     }
                                 }
-                                Err(e) => {
-                                    log::warn!("[tray-click] Failed to connect to session bus: {:?}", e);
-                                }
+                                Err(e) => log::warn!("[tray-click] Failed to connect to session bus: {:?}", e),
                             }
                         } else {
                             log::warn!("[tray-click] Failed to split id: {}", id);
                         }
-                        if !menu_shown {
-                            let _ = thread_sender.send(CustomEvent::CloudClosed { pid: 0, source: tray_source_clone });
-                        }
                     });
                 });
                 return None;
@@ -1514,23 +1591,31 @@ impl cce_ui::engine::Application for StatusApp {
                         return None;
                     }
                     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)]
+                    let dispatch_row = |label: &str, ev: CustomEvent| MenuRow {
+                        label: label.to_string(),
+                        enabled: true,
+                        separator: false,
+                        action: MenuRowAction::Dispatch(ev),
+                    };
+                    let rows = if self.adjust_position_mode {
+                        vec![dispatch_row("Done", CustomEvent::ToggleAdjustPositionMode)]
                     } else {
                         vec![
-                            (
-                                if self.status_hide_mode { "Show Modules" } else { "Hide Modules" }
-                                    .to_string(),
+                            dispatch_row(
+                                if self.status_hide_mode { "Show Modules" } else { "Hide Modules" },
                                 CustomEvent::ToggleHideModules,
                             ),
-                            ("Adjust Positions".to_string(), CustomEvent::ToggleAdjustPositionMode),
+                            dispatch_row("Adjust Positions", CustomEvent::ToggleAdjustPositionMode),
                         ]
                     };
                     self.context_menu = Some(ModuleContextMenu {
-                        module: mb.name.clone(),
-                        items,
+                        pages: vec![MenuPage { title: mb.name.clone(), rows }],
+                        page: 0,
+                        tray_target: None,
+                        min_w: 190.0,
                         hovered: None,
                         rect: (0.0, 0.0, 0.0, 0.0),
+                        row_bounds: Vec::new(),
                     });
                     self.needs_rebuild = true;
                     *needs_rebuild = true;