git.lucas.co / cce-status-interface
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 }