status bar
git clone https://git.lucas.co/cce-status-interface.git
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(¤t_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;