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