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