status bar
git clone https://git.lucas.co/cce-status-interface.git
src/cloud.rs (9.7K)
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-type`: "checkmark", "radio", or "" for an item that is not
37 /// a toggle.
38 toggle_type: String,
39 toggle_state: i32, // -1 if not toggleable, 0 if unchecked, 1 if checked
40 children: Vec<MenuItem>,
41 }
42
43 pub(crate) fn parse_menu_item(
44 id: i32,
45 mut properties: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
46 children_vals: Vec<zbus::zvariant::OwnedValue>,
47 ) -> Option<MenuItem> {
48 let type_: String = properties.remove("type")
49 .and_then(|v| {
50 let s: Result<String, _> = v.try_into();
51 s.ok()
52 })
53 .unwrap_or_default();
54 let is_separator = type_ == "separator";
55
56 let label: String = properties.remove("label")
57 .and_then(|v| {
58 let s: Result<String, _> = v.try_into();
59 s.ok()
60 })
61 .unwrap_or_default();
62
63 let enabled: bool = properties.remove("enabled")
64 .and_then(|v| {
65 let b: Result<bool, _> = v.try_into();
66 b.ok()
67 })
68 .unwrap_or(true);
69
70 let toggle_type: String = properties.remove("toggle-type")
71 .and_then(|v| {
72 let s: Result<String, _> = v.try_into();
73 s.ok()
74 })
75 .unwrap_or_default();
76
77 let toggle_state: i32 = properties.remove("toggle-state")
78 .and_then(|v| {
79 let i: Result<i32, _> = v.try_into();
80 i.ok()
81 })
82 .unwrap_or(-1);
83
84 let mut children = Vec::new();
85 for child_val in children_vals {
86 let child_val_inner = zbus::zvariant::Value::from(child_val);
87 if let Ok(child) = <(i32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, Vec<zbus::zvariant::OwnedValue>)>::try_from(child_val_inner) {
88 if let Some(parsed) = parse_menu_item(child.0, child.1, child.2) {
89 children.push(parsed);
90 }
91 }
92 }
93
94 Some(MenuItem {
95 id,
96 label,
97 enabled,
98 is_separator,
99 toggle_type,
100 toggle_state,
101 children,
102 })
103 }
104
105 /// One row of an in-surface menu — plain data so a fetched DBusMenu can ride
106 /// a `CustomEvent` into the module process's update loop.
107 #[derive(Debug, Clone)]
108 pub(crate) struct MenuRow {
109 pub label: String,
110 pub enabled: bool,
111 pub separator: bool,
112 pub action: MenuRowAction,
113 }
114
115 #[derive(Debug, Clone)]
116 pub(crate) enum MenuRowAction {
117 /// DBusMenu item: send "clicked" to the menu's owner on click.
118 Item(i32),
119 /// Navigate to a submenu page (in-surface pagination).
120 Submenu(usize),
121 /// Navigate back to the parent page.
122 Back(usize),
123 /// Dispatch a bar-internal event (the module context menu's rows).
124 Dispatch(CustomEvent),
125 /// Run ccectl with these args, detached (window picker rows).
126 Ccectl(Vec<String>),
127 /// Non-interactive (separators).
128 Inert,
129 }
130
131 #[derive(Debug, Clone)]
132 pub(crate) struct MenuPage {
133 pub title: String,
134 pub rows: Vec<MenuRow>,
135 }
136
137 /// The toolkit's leading mark (`cce_ui::widget::context_menu::MARK_*`) for a
138 /// DBusMenu toggle: a checked checkmark item wears the check, a radio item
139 /// the filled or the outlined circle, and an unchecked checkmark item and a
140 /// plain item nothing.
141 pub(crate) fn toggle_mark(toggle_type: &str, toggle_state: i32) -> &'static str {
142 use cce_ui::widget::context_menu::{MARK_CHECK, MARK_OFF, MARK_ON};
143 match (toggle_type, toggle_state) {
144 ("radio", 1) => MARK_ON,
145 ("radio", 0) => MARK_OFF,
146 (_, 1) => MARK_CHECK,
147 _ => "",
148 }
149 }
150
151 /// The row that turns a page back to `parent`: the word "Back", drawn after
152 /// the chevron-left glyph.
153 pub(crate) fn back_row(parent: usize) -> MenuRow {
154 MenuRow {
155 label: "Back".to_string(),
156 enabled: true,
157 separator: false,
158 action: MenuRowAction::Back(parent),
159 }
160 }
161
162 /// How a menu row is drawn around its text: the glyph at its left (a mark
163 /// the label leads with, or the back chevron), the label without the mark,
164 /// and the glyph at its right end (the chevron of a row that leads to a
165 /// page). The same conventions as cce-ui's context menus, laid out here
166 /// because the bar draws its menus in its own surface.
167 pub(crate) fn menu_row_glyphs(row: &MenuRow) -> (Option<&'static str>, &str, Option<&'static str>) {
168 let (mark, text) = cce_ui::widget::context_menu::split_mark(&row.label);
169 match row.action {
170 MenuRowAction::Back(_) => (Some("chevron-left"), text, None),
171 MenuRowAction::Submenu(_) => (mark, text, Some("chevron-right")),
172 _ => (mark, text, None),
173 }
174 }
175
176 /// Fetch a tray icon's DBusMenu and flatten it into in-surface pages: page 0
177 /// is the root; each enabled submenu becomes its own page (capped at 16)
178 /// reached by a `Submenu` row and left by a `Back` row. Separators and
179 /// disabled items are kept as rows for visual fidelity; toggle states become
180 /// the toolkit's leading marks (`toggle_mark`), which the menu draws as
181 /// glyphs, as it draws the page and back chevrons — see `menu_row_glyphs`.
182 pub(crate) async fn fetch_tray_menu_pages(
183 conn: &zbus::Connection,
184 destination: &str,
185 menu_path: &str,
186 ) -> Result<Vec<MenuPage>, Box<dyn std::error::Error + Send + Sync>> {
187 let menu_proxy = DBusMenuProxy::builder(conn)
188 .destination(destination)?
189 .path(menu_path)?
190 .build()
191 .await?;
192
193 let _ = menu_proxy.about_to_show(0).await;
194 let (_, layout) = menu_proxy.get_layout(0, 5, vec![]).await?;
195 let root = match parse_menu_item(layout.0, layout.1, layout.2) {
196 Some(item) => item,
197 None => return Ok(Vec::new()),
198 };
199
200 fn build(
201 item: &MenuItem,
202 page: usize,
203 parent: Option<usize>,
204 pages: &mut Vec<MenuPage>,
205 ) {
206 let mut rows = Vec::new();
207 if let Some(parent_page) = parent {
208 rows.push(back_row(parent_page));
209 }
210 // Reserve this page's slot before recursing so child pages number
211 // depth-first after it.
212 pages[page].title = if item.label.is_empty() && page == 0 {
213 "Tray Menu".to_string()
214 } else {
215 item.label.clone()
216 };
217 for child in &item.children {
218 if child.is_separator {
219 rows.push(MenuRow {
220 label: String::new(),
221 enabled: false,
222 separator: true,
223 action: MenuRowAction::Inert,
224 });
225 continue;
226 }
227 let label = format!("{}{}", toggle_mark(&child.toggle_type, child.toggle_state), child.label);
228 if !child.children.is_empty() && child.enabled && pages.len() < 16 {
229 let child_page = pages.len();
230 pages.push(MenuPage { title: String::new(), rows: Vec::new() });
231 rows.push(MenuRow {
232 label,
233 enabled: true,
234 separator: false,
235 action: MenuRowAction::Submenu(child_page),
236 });
237 build(child, child_page, Some(page), pages);
238 } else {
239 rows.push(MenuRow {
240 label,
241 enabled: child.enabled,
242 separator: false,
243 action: MenuRowAction::Item(child.id),
244 });
245 }
246 }
247 pages[page].rows = rows;
248 }
249
250 let mut pages = vec![MenuPage { title: String::new(), rows: Vec::new() }];
251 build(&root, 0, None, &mut pages);
252 if pages[0].rows.is_empty() {
253 return Ok(Vec::new());
254 }
255 Ok(pages)
256 }
257
258 /// Fire a DBusMenu "clicked" event for an in-surface menu row, detached —
259 /// the click handler must not block on D-Bus.
260 pub(crate) fn send_tray_menu_event(destination: String, menu_path: String, id: i32) {
261 std::thread::spawn(move || {
262 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
263 Ok(rt) => rt,
264 Err(_) => return,
265 };
266 rt.block_on(async move {
267 let res: Result<(), Box<dyn std::error::Error + Send + Sync>> = async {
268 let conn = zbus::Connection::session().await?;
269 let proxy = DBusMenuProxy::builder(&conn)
270 .destination(destination.as_str())?
271 .path(menu_path.as_str())?
272 .build()
273 .await?;
274 let timestamp = std::time::SystemTime::now()
275 .duration_since(std::time::UNIX_EPOCH)
276 .unwrap_or_default()
277 .as_secs() as u32;
278 let val = zbus::zvariant::Value::from("");
279 proxy.event(id, "clicked", &val, timestamp).await?;
280 Ok(())
281 }
282 .await;
283 if let Err(e) = res {
284 log::warn!("[tray-menu] clicked event failed: {:?}", e);
285 }
286 });
287 });
288 }