Skip to main content

crabshell_services/tray/
menu.rs

1//! Tray menus, over `com.canonical.dbusmenu`: the layout of the one menu
2//! that is open, kept up to date while it stays open.
3
4use std::rc::Rc;
5
6use serde::Deserialize;
7use zbus::{Connection, DeserializeDict, Type, as_variant};
8
9use super::{CALL_TIMEOUT, Service, bus_call};
10pub use crabshell_ui::{MenuEntry, MenuToggle};
11
12pub(super) const DBUSMENU: &str = "com.canonical.dbusmenu";
13
14/// The properties asked for. Leaving out `icon-data` keeps PNG bytes the
15/// menu doesn't draw off the bus.
16const PROPERTY_NAMES: &[&str] = &[
17  "type",
18  "label",
19  "enabled",
20  "visible",
21  "toggle-type",
22  "toggle-state",
23  "children-display",
24];
25
26/// The most entries a menu keeps, all levels together. Past it, the rest
27/// are left out.
28const MAX_ENTRIES: usize = 1024;
29
30/// A tray item's open menu, from [`TrayHost::menu`](super::TrayHost::menu).
31#[derive(Clone, Debug, PartialEq)]
32pub struct TrayMenu {
33  /// The item's id, as [`TrayItem::id`](super::TrayItem::id).
34  pub item: String,
35  /// The top-level entries, in order.
36  pub entries: Vec<MenuEntry>,
37}
38
39/// The menu that is open: whose it is and where its signals come from.
40pub(super) struct OpenMenu {
41  item: String,
42  bus: String,
43  owner: String,
44  path: String,
45  /// Which opening this is, so a task left from an earlier one stops.
46  opened: u64,
47  /// The newest layout read asked for, so an older reply is dropped.
48  read: u64,
49}
50
51impl OpenMenu {
52  /// The match rule for this menu's own signals, added while it is open
53  /// so other apps' menus never wake the shell.
54  fn rule(&self) -> String {
55    let OpenMenu { owner, path, .. } = self;
56    format!("type='signal',sender='{owner}',path='{path}',interface='{DBUSMENU}'")
57  }
58}
59
60impl Service {
61  /// Opens item `id`'s menu: tells the app, then reads the layout. Returns
62  /// the opening's number, or `None` if the item has no menu.
63  pub(super) fn open_menu(self: &Rc<Self>, id: &str) -> Option<u64> {
64    let open = self.with_entry(id, |entry| {
65      let path = entry.shown.as_ref()?.menu.clone();
66      Some(OpenMenu {
67        item: entry.id.clone(),
68        bus: entry.bus.clone(),
69        owner: entry.owner.clone()?,
70        path: (!path.is_empty()).then_some(path)?,
71        opened: 0,
72        read: 0,
73      })
74    });
75    let Some(mut open) = open.flatten() else {
76      tracing::warn!(id, "this tray item has no menu");
77      return None;
78    };
79    self.close_menu();
80    let (rule, bus, path) = (open.rule(), open.bus.clone(), open.path.clone());
81    let (connection, opened) = {
82      let mut state = self.state.borrow_mut();
83      let state = state.as_mut()?;
84      state.menu_count += 1;
85      open.opened = state.menu_count;
86      state.open = Some(open);
87      (state.connection.clone(), state.menu_count)
88    };
89    let Some(connection) = connection else {
90      return Some(opened);
91    };
92    let service = Rc::clone(self);
93    self.shared.spawn(async move {
94      if let Err(error) = bus_call(&connection, "AddMatch", &rule).await {
95        tracing::warn!(%error, "can't follow a tray menu's changes");
96      }
97      let about = connection.call_method(
98        Some(bus.as_str()),
99        path.as_str(),
100        Some(DBUSMENU),
101        "AboutToShow",
102        &0,
103      );
104      // Some apps don't have it; the layout is read either way.
105      if let Err(error) = service.shared.timeout(CALL_TIMEOUT, about).await {
106        tracing::debug!(%error, "a tray menu refused AboutToShow");
107      }
108      if service.is_open(|open| open.opened == opened) {
109        service.read_menu(&connection);
110      }
111    });
112    Some(opened)
113  }
114
115  /// Tells the open menu's app that submenu `entry` opens, and reads the
116  /// layout again if the app says the submenu changed.
117  pub(super) fn open_submenu(self: &Rc<Self>, entry: i32) {
118    let asked = {
119      let state = self.state.borrow();
120      let state = state.as_ref();
121      let open = state.and_then(|state| state.open.as_ref());
122      let connection = state.and_then(|state| state.connection.clone());
123      open
124        .zip(connection)
125        .map(|(open, connection)| (open.bus.clone(), open.path.clone(), open.opened, connection))
126    };
127    let Some((bus, path, opened, connection)) = asked else {
128      return tracing::warn!(entry, "no tray menu is open");
129    };
130    let service = Rc::clone(self);
131    self.shared.spawn(async move {
132      let about = connection.call_method(
133        Some(bus.as_str()),
134        path.as_str(),
135        Some(DBUSMENU),
136        "AboutToShow",
137        &entry,
138      );
139      let changed = service
140        .shared
141        .timeout(CALL_TIMEOUT, about)
142        .await
143        .and_then(|reply| reply.body().deserialize::<bool>());
144      match changed {
145        Ok(true) if service.is_open(|open| open.opened == opened) => service.read_menu(&connection),
146        Ok(_) => {}
147        Err(error) => tracing::debug!(entry, %error, "a tray menu refused AboutToShow"),
148      }
149    });
150  }
151
152  /// Whether a menu is open and `test` holds for it.
153  pub(super) fn is_open(&self, test: impl FnOnce(&OpenMenu) -> bool) -> bool {
154    let state = self.state.borrow();
155    let open = state.as_ref().and_then(|state| state.open.as_ref());
156    open.is_some_and(test)
157  }
158
159  /// Closes the open menu, if any, and stops following it.
160  pub(super) fn close_menu(&self) {
161    let (open, connection) = {
162      let mut state = self.state.borrow_mut();
163      let Some(state) = state.as_mut() else {
164        return;
165      };
166      let open = state.open.take();
167      // Set even when it is already `None`, so a menu's popup hears that
168      // its opening ended, as when the first read fails.
169      if open.is_some() || state.menu.try_with(Option::is_some) == Some(true) {
170        state.menu.try_set(None);
171      }
172      (open, state.connection.clone())
173    };
174    if let (Some(open), Some(connection)) = (open, connection) {
175      let rule = open.rule();
176      self.shared.spawn(async move {
177        if let Err(error) = bus_call(&connection, "RemoveMatch", &rule).await {
178          tracing::debug!(%error, "a tray menu's match was already gone");
179        }
180      });
181    }
182  }
183
184  /// Closes the menu if it is item `id`'s, as when the item goes.
185  pub(super) fn close_menu_of(&self, id: &str) {
186    if self.is_open(|open| open.item == id) {
187      self.close_menu();
188    }
189  }
190
191  /// Closes the open menu if it is still the one `open_menu` numbered
192  /// `opened`, not one opened since.
193  pub(super) fn close_menu_opened(&self, opened: u64) {
194    if self.follows(opened) {
195      self.close_menu();
196    }
197  }
198
199  /// Whether the open menu is the one `open_menu` numbered `opened`.
200  pub(super) fn follows(&self, opened: u64) -> bool {
201    self.is_open(|open| open.opened == opened)
202  }
203
204  /// Reads the open menu again when it says it changed.
205  pub(super) fn menu_changed(self: &Rc<Self>, connection: &Connection, sender: &str, path: &str) {
206    if self.is_open(|open| open.owner == sender && open.path == path) {
207      self.read_menu(connection);
208    }
209  }
210
211  /// Reads the open menu's whole layout and shows it, unless the menu
212  /// closed or a newer read was asked for meanwhile. A failed read is
213  /// logged and closes the menu, so a broken one stops waking the shell.
214  fn read_menu(self: &Rc<Self>, connection: &Connection) {
215    let asked = {
216      let mut state = self.state.borrow_mut();
217      let Some(state) = state.as_mut() else {
218        return;
219      };
220      state.menu_count += 1;
221      let read = state.menu_count;
222      let Some(open) = state.open.as_mut() else {
223        return;
224      };
225      open.read = read;
226      (open.item.clone(), open.bus.clone(), open.path.clone(), read)
227    };
228    let (item, bus, path, read) = asked;
229    let service = Rc::clone(self);
230    let connection = connection.clone();
231    self.shared.spawn(async move {
232      let args = (0, -1, PROPERTY_NAMES);
233      let call = connection.call_method(
234        Some(bus.as_str()),
235        path.as_str(),
236        Some(DBUSMENU),
237        "GetLayout",
238        &args,
239      );
240      let layout = service
241        .shared
242        .timeout(CALL_TIMEOUT, call)
243        .await
244        .and_then(|reply| reply.body().deserialize::<(u32, Layout)>());
245      let newest = |open: &OpenMenu| open.read == read;
246      let children = match layout {
247        Ok((_, Layout(_, _, children))) => children,
248        Err(error) => {
249          tracing::warn!(item, %error, "can't read a tray item's menu; closing it");
250          if service.is_open(newest) {
251            service.close_menu();
252          }
253          return;
254        }
255      };
256      let mut left = MAX_ENTRIES;
257      let entries = entries(children, &mut left);
258      if left == 0 {
259        tracing::debug!(
260          item,
261          "a tray menu kept only its first {MAX_ENTRIES} entries"
262        );
263      }
264      let menu = Some(TrayMenu { item, entries });
265      let state = service.state.borrow();
266      let Some(state) = state.as_ref() else {
267        return;
268      };
269      // An unchanged layout isn't set again, so nothing repaints.
270      let changed = state.menu.try_with(|shown| *shown != menu) == Some(true);
271      if state.open.as_ref().is_some_and(newest) && changed {
272        state.menu.set(menu);
273      }
274    });
275  }
276}
277
278/// One node of `GetLayout`'s tree: id, properties and children.
279#[derive(Deserialize, Type)]
280#[cfg_attr(test, derive(Clone, serde::Serialize))]
281pub(super) struct Layout(pub(super) i32, pub(super) Props, pub(super) Vec<Child>);
282
283/// A child node, which the spec wraps in a variant.
284#[derive(Deserialize, Type)]
285#[cfg_attr(test, derive(Clone, serde::Serialize))]
286#[zbus(signature = "v")]
287pub(super) struct Child(#[serde(with = "as_variant")] pub(super) Layout);
288
289/// The properties the menu reads. One of the wrong type fails the read.
290#[derive(Default, DeserializeDict, Type)]
291#[cfg_attr(test, derive(Clone, zbus::SerializeDict))]
292#[zbus(signature = "a{sv}", rename_all = "kebab-case")]
293pub(super) struct Props {
294  #[zbus(rename = "type")]
295  pub(super) kind: Option<String>,
296  pub(super) label: Option<String>,
297  pub(super) enabled: Option<bool>,
298  pub(super) visible: Option<bool>,
299  pub(super) toggle_type: Option<String>,
300  pub(super) toggle_state: Option<i32>,
301  pub(super) children_display: Option<String>,
302}
303
304/// The visible entries of `children`, with the spec's defaults, until
305/// `left` entries have been taken.
306pub(super) fn entries(children: Vec<Child>, left: &mut usize) -> Vec<MenuEntry> {
307  let mut shown = Vec::new();
308  for Child(Layout(id, props, children)) in children {
309    if props.visible == Some(false) {
310      continue;
311    }
312    let Some(fewer) = left.checked_sub(1) else {
313      break;
314    };
315    *left = fewer;
316    let children = entries(children, left);
317    shown.push(MenuEntry {
318      id,
319      label: without_access_keys(props.label.as_deref().unwrap_or_default()),
320      separator: props.kind.as_deref() == Some("separator"),
321      enabled: props.enabled.unwrap_or(true),
322      toggle: match props.toggle_type.as_deref() {
323        Some("checkmark") => Some(MenuToggle::Checkmark),
324        Some("radio") => Some(MenuToggle::Radio),
325        _ => None,
326      },
327      checked: props.toggle_state == Some(1),
328      submenu: props.children_display.as_deref() == Some("submenu") || !children.is_empty(),
329      children,
330    });
331  }
332  shown
333}
334
335/// `label` without the underscore before an access key; `__` is one
336/// underscore. The bar takes no keyboard input, so the keys go unused.
337pub(super) fn without_access_keys(label: &str) -> String {
338  let mut shown = String::with_capacity(label.len());
339  let mut chars = label.chars();
340  while let Some(char) = chars.next() {
341    match char {
342      '_' => shown.extend(chars.next()),
343      char => shown.push(char),
344    }
345  }
346  shown
347}