status bar
git clone https://git.lucas.co/cce-status-interface.git
src/cloud.rs (8.1K)
1 //! Menu machinery: the in-surface menu model — fetched DBusMenu layouts and
2 //! bar-built menus alike are flattened into pages of plain-data rows that
3 //! ride a CustomEvent into the module's update loop, where the module's own
4 //! surface expands to show them.
5
6 use crate::CustomEvent;
7
8 #[zbus::proxy(
9 interface = "com.canonical.dbusmenu",
10 default_path = "/StatusNotifierItem/menu"
11 )]
12 pub(crate) trait DBusMenu {
13 fn get_layout(
14 &self,
15 parent_id: i32,
16 recursion_depth: i32,
17 property_names: Vec<String>,
18 ) -> zbus::Result<(u32, (i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>))>;
19
20 fn event(
21 &self,
22 id: i32,
23 event_id: &str,
24 data: &zbus::zvariant::Value<'_>,
25 timestamp: u32,
26 ) -> zbus::Result<()>;
27
28 fn about_to_show(&self, id: i32) -> zbus::Result<bool>;
29 }
30
31 pub(crate) struct MenuItem {
32 id: i32,
33 label: String,
34 enabled: bool,
35 is_separator: bool,
36 toggle_state: i32, // -1 if not toggleable, 0 if unchecked, 1 if checked
37 children: Vec<MenuItem>,
38 }
39
40 pub(crate) fn parse_menu_item(
41 id: i32,
42 mut properties: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
43 children_vals: Vec<zbus::zvariant::OwnedValue>,
44 ) -> Option<MenuItem> {
45 let type_: String = properties.remove("type")
46 .and_then(|v| {
47 let s: Result<String, _> = v.try_into();
48 s.ok()
49 })
50 .unwrap_or_default();
51 let is_separator = type_ == "separator";
52
53 let label: String = properties.remove("label")
54 .and_then(|v| {
55 let s: Result<String, _> = v.try_into();
56 s.ok()
57 })
58 .unwrap_or_default();
59
60 let enabled: bool = properties.remove("enabled")
61 .and_then(|v| {
62 let b: Result<bool, _> = v.try_into();
63 b.ok()
64 })
65 .unwrap_or(true);
66
67 let toggle_state: i32 = properties.remove("toggle-state")
68 .and_then(|v| {
69 let i: Result<i32, _> = v.try_into();
70 i.ok()
71 })
72 .unwrap_or(-1);
73
74 let mut children = Vec::new();
75 for child_val in children_vals {
76 let child_val_inner = zbus::zvariant::Value::from(child_val);
77 if let Ok(child) = <(i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>)>::try_from(child_val_inner) {
78 if let Some(parsed) = parse_menu_item(child.0, child.1, child.2) {
79 children.push(parsed);
80 }
81 }
82 }
83
84 Some(MenuItem {
85 id,
86 label,
87 enabled,
88 is_separator,
89 toggle_state,
90 children,
91 })
92 }
93
94 /// One row of an in-surface menu — plain data so a fetched DBusMenu can ride
95 /// a `CustomEvent` into the module process's update loop.
96 #[derive(Debug, Clone)]
97 pub(crate) struct MenuRow {
98 pub label: String,
99 pub enabled: bool,
100 pub separator: bool,
101 pub action: MenuRowAction,
102 }
103
104 #[derive(Debug, Clone)]
105 pub(crate) enum MenuRowAction {
106 /// DBusMenu item: send "clicked" to the menu's owner on click.
107 Item(i32),
108 /// Navigate to a submenu page (in-surface pagination).
109 Submenu(usize),
110 /// Navigate back to the parent page.
111 Back(usize),
112 /// Dispatch a bar-internal event (the module context menu's rows).
113 Dispatch(CustomEvent),
114 /// Run ccectl with these args, detached (window picker rows).
115 Ccectl(Vec<String>),
116 /// Non-interactive (separators).
117 Inert,
118 }
119
120 #[derive(Debug, Clone)]
121 pub(crate) struct MenuPage {
122 pub title: String,
123 pub rows: Vec<MenuRow>,
124 }
125
126 /// Fetch a tray icon's DBusMenu and flatten it into in-surface pages: page 0
127 /// is the root; each enabled submenu becomes its own page (capped at 16)
128 /// reached by a `Submenu` row and left by the "< Back" row. Separators and
129 /// disabled items are kept as rows for visual fidelity; toggle states become
130 /// `[x]`/`[ ]` label prefixes, exactly like the popup renderer they replace.
131 pub(crate) async fn fetch_tray_menu_pages(
132 conn: &zbus::Connection,
133 destination: &str,
134 menu_path: &str,
135 ) -> Result<Vec<MenuPage>, Box<dyn std::error::Error + Send + Sync>> {
136 let menu_proxy = DBusMenuProxy::builder(conn)
137 .destination(destination)?
138 .path(menu_path)?
139 .build()
140 .await?;
141
142 let _ = menu_proxy.about_to_show(0).await;
143 let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
144 let root = match parse_menu_item(layout.0, layout.1, layout.2) {
145 Some(item) => item,
146 None => return Ok(Vec::new()),
147 };
148
149 fn build(
150 item: &MenuItem,
151 page: usize,
152 parent: Option<usize>,
153 pages: &mut Vec<MenuPage>,
154 ) {
155 let mut rows = Vec::new();
156 if let Some(parent_page) = parent {
157 rows.push(MenuRow {
158 label: "< Back".to_string(),
159 enabled: true,
160 separator: false,
161 action: MenuRowAction::Back(parent_page),
162 });
163 }
164 // Reserve this page's slot before recursing so child pages number
165 // depth-first after it.
166 pages[page].title = if item.label.is_empty() && page == 0 {
167 "Tray Menu".to_string()
168 } else {
169 item.label.clone()
170 };
171 for child in &item.children {
172 if child.is_separator {
173 rows.push(MenuRow {
174 label: String::new(),
175 enabled: false,
176 separator: true,
177 action: MenuRowAction::Inert,
178 });
179 continue;
180 }
181 let mut label = if child.toggle_state == 1 {
182 format!("[x] {}", child.label)
183 } else if child.toggle_state == 0 {
184 format!("[ ] {}", child.label)
185 } else {
186 child.label.clone()
187 };
188 if !child.children.is_empty() && child.enabled && pages.len() < 16 {
189 label = format!("{} >", label);
190 let child_page = pages.len();
191 pages.push(MenuPage { title: String::new(), rows: Vec::new() });
192 rows.push(MenuRow {
193 label,
194 enabled: true,
195 separator: false,
196 action: MenuRowAction::Submenu(child_page),
197 });
198 build(child, child_page, Some(page), pages);
199 } else {
200 rows.push(MenuRow {
201 label,
202 enabled: child.enabled,
203 separator: false,
204 action: MenuRowAction::Item(child.id),
205 });
206 }
207 }
208 pages[page].rows = rows;
209 }
210
211 let mut pages = vec![MenuPage { title: String::new(), rows: Vec::new() }];
212 build(&root, 0, None, &mut pages);
213 if pages[0].rows.is_empty() {
214 return Ok(Vec::new());
215 }
216 Ok(pages)
217 }
218
219 /// Fire a DBusMenu "clicked" event for an in-surface menu row, detached —
220 /// the click handler must not block on D-Bus.
221 pub(crate) fn send_tray_menu_event(destination: String, menu_path: String, id: i32) {
222 std::thread::spawn(move || {
223 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
224 Ok(rt) => rt,
225 Err(_) => return,
226 };
227 rt.block_on(async move {
228 let res: Result<(), Box<dyn std::error::Error + Send + Sync>> = async {
229 let conn = zbus::Connection::session().await?;
230 let proxy = DBusMenuProxy::builder(&conn)
231 .destination(destination.as_str())?
232 .path(menu_path.as_str())?
233 .build()
234 .await?;
235 let timestamp = std::time::SystemTime::now()
236 .duration_since(std::time::UNIX_EPOCH)
237 .unwrap_or_default()
238 .as_secs() as u32;
239 let val = zbus::zvariant::Value::from("");
240 proxy.event(id, "clicked", &val, timestamp).await?;
241 Ok(())
242 }
243 .await;
244 if let Err(e) = res {
245 log::warn!("[tray-menu] clicked event failed: {:?}", e);
246 }
247 });
248 });
249 }