1use 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
14const PROPERTY_NAMES: &[&str] = &[
17 "type",
18 "label",
19 "enabled",
20 "visible",
21 "toggle-type",
22 "toggle-state",
23 "children-display",
24];
25
26const MAX_ENTRIES: usize = 1024;
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct TrayMenu {
33 pub item: String,
35 pub entries: Vec<MenuEntry>,
37}
38
39pub(super) struct OpenMenu {
41 item: String,
42 bus: String,
43 owner: String,
44 path: String,
45 opened: u64,
47 read: u64,
49}
50
51impl OpenMenu {
52 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 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 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 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 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 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 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 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 pub(super) fn close_menu_opened(&self, opened: u64) {
194 if self.follows(opened) {
195 self.close_menu();
196 }
197 }
198
199 pub(super) fn follows(&self, opened: u64) -> bool {
201 self.is_open(|open| open.opened == opened)
202 }
203
204 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 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 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#[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#[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#[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
304pub(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
335pub(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}