use std::rc::Rc;
use serde::Deserialize;
use zbus::{Connection, DeserializeDict, Type, as_variant};
use super::{CALL_TIMEOUT, Service, bus_call};
pub use crabshell_ui::{MenuEntry, MenuToggle};
pub(super) const DBUSMENU: &str = "com.canonical.dbusmenu";
const PROPERTY_NAMES: &[&str] = &[
"type",
"label",
"enabled",
"visible",
"toggle-type",
"toggle-state",
"children-display",
];
const MAX_ENTRIES: usize = 1024;
#[derive(Clone, Debug, PartialEq)]
pub struct TrayMenu {
pub item: String,
pub entries: Vec<MenuEntry>,
}
pub(super) struct OpenMenu {
item: String,
bus: String,
owner: String,
path: String,
opened: u64,
read: u64,
}
impl OpenMenu {
fn rule(&self) -> String {
let OpenMenu { owner, path, .. } = self;
format!("type='signal',sender='{owner}',path='{path}',interface='{DBUSMENU}'")
}
}
impl Service {
pub(super) fn open_menu(self: &Rc<Self>, id: &str) -> Option<u64> {
let open = self.with_entry(id, |entry| {
let path = entry.shown.as_ref()?.menu.clone();
Some(OpenMenu {
item: entry.id.clone(),
bus: entry.bus.clone(),
owner: entry.owner.clone()?,
path: (!path.is_empty()).then_some(path)?,
opened: 0,
read: 0,
})
});
let Some(mut open) = open.flatten() else {
tracing::warn!(id, "this tray item has no menu");
return None;
};
self.close_menu();
let (rule, bus, path) = (open.rule(), open.bus.clone(), open.path.clone());
let (connection, opened) = {
let mut state = self.state.borrow_mut();
let state = state.as_mut()?;
state.menu_count += 1;
open.opened = state.menu_count;
state.open = Some(open);
(state.connection.clone(), state.menu_count)
};
let Some(connection) = connection else {
return Some(opened);
};
let service = Rc::clone(self);
self.shared.spawn(async move {
if let Err(error) = bus_call(&connection, "AddMatch", &rule).await {
tracing::warn!(%error, "can't follow a tray menu's changes");
}
let about = connection.call_method(
Some(bus.as_str()),
path.as_str(),
Some(DBUSMENU),
"AboutToShow",
&0,
);
if let Err(error) = service.shared.timeout(CALL_TIMEOUT, about).await {
tracing::debug!(%error, "a tray menu refused AboutToShow");
}
if service.is_open(|open| open.opened == opened) {
service.read_menu(&connection);
}
});
Some(opened)
}
pub(super) fn open_submenu(self: &Rc<Self>, entry: i32) {
let asked = {
let state = self.state.borrow();
let state = state.as_ref();
let open = state.and_then(|state| state.open.as_ref());
let connection = state.and_then(|state| state.connection.clone());
open
.zip(connection)
.map(|(open, connection)| (open.bus.clone(), open.path.clone(), open.opened, connection))
};
let Some((bus, path, opened, connection)) = asked else {
return tracing::warn!(entry, "no tray menu is open");
};
let service = Rc::clone(self);
self.shared.spawn(async move {
let about = connection.call_method(
Some(bus.as_str()),
path.as_str(),
Some(DBUSMENU),
"AboutToShow",
&entry,
);
let changed = service
.shared
.timeout(CALL_TIMEOUT, about)
.await
.and_then(|reply| reply.body().deserialize::<bool>());
match changed {
Ok(true) if service.is_open(|open| open.opened == opened) => service.read_menu(&connection),
Ok(_) => {}
Err(error) => tracing::debug!(entry, %error, "a tray menu refused AboutToShow"),
}
});
}
pub(super) fn is_open(&self, test: impl FnOnce(&OpenMenu) -> bool) -> bool {
let state = self.state.borrow();
let open = state.as_ref().and_then(|state| state.open.as_ref());
open.is_some_and(test)
}
pub(super) fn close_menu(&self) {
let (open, connection) = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
let open = state.open.take();
if open.is_some() || state.menu.try_with(Option::is_some) == Some(true) {
state.menu.try_set(None);
}
(open, state.connection.clone())
};
if let (Some(open), Some(connection)) = (open, connection) {
let rule = open.rule();
self.shared.spawn(async move {
if let Err(error) = bus_call(&connection, "RemoveMatch", &rule).await {
tracing::debug!(%error, "a tray menu's match was already gone");
}
});
}
}
pub(super) fn close_menu_of(&self, id: &str) {
if self.is_open(|open| open.item == id) {
self.close_menu();
}
}
pub(super) fn close_menu_opened(&self, opened: u64) {
if self.follows(opened) {
self.close_menu();
}
}
pub(super) fn follows(&self, opened: u64) -> bool {
self.is_open(|open| open.opened == opened)
}
pub(super) fn menu_changed(self: &Rc<Self>, connection: &Connection, sender: &str, path: &str) {
if self.is_open(|open| open.owner == sender && open.path == path) {
self.read_menu(connection);
}
}
fn read_menu(self: &Rc<Self>, connection: &Connection) {
let asked = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
state.menu_count += 1;
let read = state.menu_count;
let Some(open) = state.open.as_mut() else {
return;
};
open.read = read;
(open.item.clone(), open.bus.clone(), open.path.clone(), read)
};
let (item, bus, path, read) = asked;
let service = Rc::clone(self);
let connection = connection.clone();
self.shared.spawn(async move {
let args = (0, -1, PROPERTY_NAMES);
let call = connection.call_method(
Some(bus.as_str()),
path.as_str(),
Some(DBUSMENU),
"GetLayout",
&args,
);
let layout = service
.shared
.timeout(CALL_TIMEOUT, call)
.await
.and_then(|reply| reply.body().deserialize::<(u32, Layout)>());
let newest = |open: &OpenMenu| open.read == read;
let children = match layout {
Ok((_, Layout(_, _, children))) => children,
Err(error) => {
tracing::warn!(item, %error, "can't read a tray item's menu; closing it");
if service.is_open(newest) {
service.close_menu();
}
return;
}
};
let mut left = MAX_ENTRIES;
let entries = entries(children, &mut left);
if left == 0 {
tracing::debug!(
item,
"a tray menu kept only its first {MAX_ENTRIES} entries"
);
}
let menu = Some(TrayMenu { item, entries });
let state = service.state.borrow();
let Some(state) = state.as_ref() else {
return;
};
let changed = state.menu.try_with(|shown| *shown != menu) == Some(true);
if state.open.as_ref().is_some_and(newest) && changed {
state.menu.set(menu);
}
});
}
}
#[derive(Deserialize, Type)]
#[cfg_attr(test, derive(Clone, serde::Serialize))]
pub(super) struct Layout(pub(super) i32, pub(super) Props, pub(super) Vec<Child>);
#[derive(Deserialize, Type)]
#[cfg_attr(test, derive(Clone, serde::Serialize))]
#[zbus(signature = "v")]
pub(super) struct Child(#[serde(with = "as_variant")] pub(super) Layout);
#[derive(Default, DeserializeDict, Type)]
#[cfg_attr(test, derive(Clone, zbus::SerializeDict))]
#[zbus(signature = "a{sv}", rename_all = "kebab-case")]
pub(super) struct Props {
#[zbus(rename = "type")]
pub(super) kind: Option<String>,
pub(super) label: Option<String>,
pub(super) enabled: Option<bool>,
pub(super) visible: Option<bool>,
pub(super) toggle_type: Option<String>,
pub(super) toggle_state: Option<i32>,
pub(super) children_display: Option<String>,
}
pub(super) fn entries(children: Vec<Child>, left: &mut usize) -> Vec<MenuEntry> {
let mut shown = Vec::new();
for Child(Layout(id, props, children)) in children {
if props.visible == Some(false) {
continue;
}
let Some(fewer) = left.checked_sub(1) else {
break;
};
*left = fewer;
let children = entries(children, left);
shown.push(MenuEntry {
id,
label: without_access_keys(props.label.as_deref().unwrap_or_default()),
separator: props.kind.as_deref() == Some("separator"),
enabled: props.enabled.unwrap_or(true),
toggle: match props.toggle_type.as_deref() {
Some("checkmark") => Some(MenuToggle::Checkmark),
Some("radio") => Some(MenuToggle::Radio),
_ => None,
},
checked: props.toggle_state == Some(1),
submenu: props.children_display.as_deref() == Some("submenu") || !children.is_empty(),
children,
});
}
shown
}
pub(super) fn without_access_keys(label: &str) -> String {
let mut shown = String::with_capacity(label.len());
let mut chars = label.chars();
while let Some(char) = chars.next() {
match char {
'_' => shown.extend(chars.next()),
char => shown.push(char),
}
}
shown
}