system_tray/
menu.rs

1use crate::dbus::dbus_menu_proxy::{MenuLayout, PropertiesUpdate, UpdatedProps};
2use crate::error::{Error, Result};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::fmt::{Debug, Formatter};
6use zbus::zvariant::{Array, OwnedValue, Structure, Value};
7
8/// A menu that should be displayed when clicking corresponding tray icon
9#[derive(Debug, Clone)]
10pub struct TrayMenu {
11    /// The unique identifier of the menu
12    pub id: u32,
13    /// A recursive list of submenus
14    pub submenus: Vec<MenuItem>,
15}
16
17/// List of properties taken from:
18/// <https://github.com/AyatanaIndicators/libdbusmenu/blob/4d03141aea4e2ad0f04ab73cf1d4f4bcc4a19f6c/libdbusmenu-glib/dbus-menu.xml#L75>
19#[derive(Clone, Deserialize, Default)]
20pub struct MenuItem {
21    /// Unique numeric id
22    pub id: i32,
23
24    /// Either a standard menu item or a separator [`MenuType`]
25    pub menu_type: MenuType,
26    /// Text of the item, except that:
27    ///  - two consecutive underscore characters "__" are displayed as a
28    ///    single underscore,
29    ///  - any remaining underscore characters are not displayed at all,
30    ///  - the first of those remaining underscore characters (unless it is
31    ///    the last character in the string) indicates that the following
32    ///    character is the access key.
33    pub label: Option<String>,
34    /// Whether the item can be activated or not.
35    pub enabled: bool,
36    /// True if the item is visible in the menu.
37    pub visible: bool,
38    /// Icon name of the item, following the freedesktop.org icon spec.
39    pub icon_name: Option<String>,
40    /// PNG data of the icon.
41    pub icon_data: Option<Vec<u8>>,
42    /// The shortcut of the item. Each array represents the key press
43    /// in the list of keypresses. Each list of strings contains a list of
44    /// modifiers and then the key that is used. The modifier strings
45    /// allowed are: "Control", "Alt", "Shift" and "Super".
46    ///
47    /// - A simple shortcut like Ctrl+S is represented as:
48    ///   [["Control", "S"]]
49    /// - A complex shortcut like Ctrl+Q, Alt+X is represented as:
50    ///   [["Control", "Q"], ["Alt", "X"]]
51    pub shortcut: Option<Vec<Vec<String>>>,
52    /// How the menuitem feels the information it's displaying to the
53    /// user should be presented.
54    /// See [`ToggleType`].
55    pub toggle_type: ToggleType,
56    /// Describe the current state of a "togglable" item.
57    /// See [`ToggleState`].
58    ///
59    /// # Note:
60    /// The implementation does not itself handle ensuring that only one
61    /// item in a radio group is set to "on", or that a group does not have
62    /// "on" and "indeterminate" items simultaneously; maintaining this
63    /// policy is up to the toolkit wrappers.
64    pub toggle_state: ToggleState,
65    /// If the menu item has children this property should be set to
66    /// "submenu".
67    pub children_display: Option<String>,
68    /// How the menuitem feels the information it's displaying to the
69    /// user should be presented.
70    /// See [`Disposition`]
71    pub disposition: Disposition,
72    /// Nested submenu items belonging to this item.
73    pub submenu: Vec<MenuItem>,
74}
75
76impl Debug for MenuItem {
77    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("MenuItem")
79            .field("id", &self.id)
80            .field("menu_type", &self.menu_type)
81            .field("label", &self.label)
82            .field("enabled", &self.enabled)
83            .field("visible", &self.visible)
84            .field("icon_name", &self.icon_name)
85            .field(
86                "icon_data",
87                &format!(
88                    "<length: {}>",
89                    self.icon_data
90                        .as_ref()
91                        .map_or("none".to_string(), |d| d.len().to_string())
92                ),
93            )
94            .field("shortcut", &self.shortcut)
95            .field("toggle_type", &self.toggle_type)
96            .field("toggle_state", &self.toggle_state)
97            .field("children_display", &self.children_display)
98            .field("disposition", &self.disposition)
99            .field("submenu", &self.submenu)
100            .finish()
101    }
102}
103
104#[derive(Debug, Clone, Deserialize, Default)]
105pub struct MenuDiff {
106    pub id: i32,
107    pub update: MenuItemUpdate,
108    pub remove: Vec<String>,
109}
110
111#[derive(Clone, Deserialize, Default)]
112pub struct MenuItemUpdate {
113    /// Text of the item, except that:
114    ///  - two consecutive underscore characters "__" are displayed as a
115    ///    single underscore,
116    ///  - any remaining underscore characters are not displayed at all,
117    ///  - the first of those remaining underscore characters (unless it is
118    ///    the last character in the string) indicates that the following
119    ///    character is the access key.
120    pub label: Option<Option<String>>,
121    /// Whether the item can be activated or not.
122    pub enabled: Option<bool>,
123    /// True if the item is visible in the menu.
124    pub visible: Option<bool>,
125    /// Icon name of the item, following the freedesktop.org icon spec.
126    pub icon_name: Option<Option<String>>,
127    /// PNG data of the icon.
128    pub icon_data: Option<Option<Vec<u8>>>,
129    /// Describe the current state of a "togglable" item.
130    /// See [`ToggleState`].
131    ///
132    /// # Note:
133    /// The implementation does not itself handle ensuring that only one
134    /// item in a radio group is set to "on", or that a group does not have
135    /// "on" and "indeterminate" items simultaneously; maintaining this
136    /// policy is up to the toolkit wrappers.
137    pub toggle_state: Option<ToggleState>,
138    /// How the menuitem feels the information it's displaying to the
139    /// user should be presented.
140    /// See [`Disposition`]
141    pub disposition: Option<Disposition>,
142}
143
144impl Debug for MenuItemUpdate {
145    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("MenuItemUpdate")
147            .field("label", &self.label)
148            .field("enabled", &self.enabled)
149            .field("visible", &self.visible)
150            .field("icon_name", &self.icon_name)
151            .field(
152                "icon_data",
153                &format!(
154                    "<length: {:?}>",
155                    self.icon_data.as_ref().map(|d| d
156                        .as_ref()
157                        .map_or("none".to_string(), |d| d.len().to_string()))
158                ),
159            )
160            .field("toggle_state", &self.toggle_state)
161            .field("disposition", &self.disposition)
162            .finish()
163    }
164}
165
166#[derive(Debug, Deserialize, Copy, Clone, Eq, PartialEq, Default)]
167pub enum MenuType {
168    ///  a separator
169    Separator,
170    /// an item which can be clicked to trigger an action or show another menu
171    #[default]
172    Standard,
173}
174
175impl From<&str> for MenuType {
176    fn from(value: &str) -> Self {
177        match value {
178            "separator" => Self::Separator,
179            _ => Self::default(),
180        }
181    }
182}
183
184#[derive(Debug, Deserialize, Copy, Clone, Eq, PartialEq, Default)]
185pub enum ToggleType {
186    /// Item is an independent togglable item
187    Checkmark,
188    /// Item is part of a group where only one item can be
189    /// toggled at a time
190    Radio,
191    /// Item cannot be toggled
192    #[default]
193    CannotBeToggled,
194}
195
196impl From<&str> for ToggleType {
197    fn from(value: &str) -> Self {
198        match value {
199            "checkmark" => Self::Checkmark,
200            "radio" => Self::Radio,
201            _ => Self::default(),
202        }
203    }
204}
205
206/// Describe the current state of a "togglable" item.
207#[derive(Debug, Deserialize, Copy, Clone, Eq, PartialEq, Default)]
208pub enum ToggleState {
209    /// This item is toggled
210    #[default]
211    On,
212    /// Item is not toggled
213    Off,
214    /// Item is not toggalble
215    Indeterminate,
216}
217
218impl From<i32> for ToggleState {
219    fn from(value: i32) -> Self {
220        match value {
221            0 => Self::Off,
222            1 => Self::On,
223            _ => Self::Indeterminate,
224        }
225    }
226}
227
228#[derive(Debug, Deserialize, Copy, Clone, Eq, PartialEq, Default)]
229pub enum Disposition {
230    /// a standard menu item
231    #[default]
232    Normal,
233    /// providing additional information to the user
234    Informative,
235    ///  looking at potentially harmful results
236    Warning,
237    /// something bad could potentially happen
238    Alert,
239}
240
241impl From<&str> for Disposition {
242    fn from(value: &str) -> Self {
243        match value {
244            "informative" => Self::Informative,
245            "warning" => Self::Warning,
246            "alert" => Self::Alert,
247            _ => Self::default(),
248        }
249    }
250}
251
252impl TryFrom<MenuLayout> for TrayMenu {
253    type Error = Error;
254
255    fn try_from(value: MenuLayout) -> Result<Self> {
256        let submenus = value
257            .fields
258            .submenus
259            .iter()
260            .map(MenuItem::try_from)
261            .collect::<std::result::Result<_, _>>()?;
262
263        Ok(Self {
264            id: value.id,
265            submenus,
266        })
267    }
268}
269
270impl TryFrom<&OwnedValue> for MenuItem {
271    type Error = Error;
272
273    fn try_from(value: &OwnedValue) -> Result<Self> {
274        let structure = value.downcast_ref::<&Structure>()?;
275
276        let mut fields = structure.fields().iter();
277
278        // defaults for enabled/visible are true
279        // and setting here avoids having to provide a full `Default` impl
280        let mut menu = MenuItem {
281            enabled: true,
282            visible: true,
283            ..Default::default()
284        };
285
286        if let Some(Value::I32(id)) = fields.next() {
287            menu.id = *id;
288        }
289
290        if let Some(Value::Dict(dict)) = fields.next() {
291            menu.children_display = dict
292                .get::<&str, &str>(&"children-display")?
293                .map(str::to_string);
294
295            // see: https://github.com/gnustep/libs-dbuskit/blob/4dc9b56216e46e0e385b976b0605b965509ebbbd/Bundles/DBusMenu/com.canonical.dbusmenu.xml#L76
296            menu.label = dict
297                .get::<&str, &str>(&"label")?
298                .map(|label| label.replace('_', ""));
299
300            if let Some(enabled) = dict.get::<&str, bool>(&"enabled")? {
301                menu.enabled = enabled;
302            }
303
304            if let Some(visible) = dict.get::<&str, bool>(&"visible")? {
305                menu.visible = visible;
306            }
307
308            menu.icon_name = dict.get::<&str, &str>(&"icon-name")?.map(str::to_string);
309
310            if let Some(array) = dict.get::<&str, &Array>(&"icon-data")? {
311                menu.icon_data = Some(get_icon_data(array)?);
312            }
313
314            if let Some(disposition) = dict
315                .get::<&str, &str>(&"disposition")
316                .ok()
317                .flatten()
318                .map(Disposition::from)
319            {
320                menu.disposition = disposition;
321            }
322
323            menu.toggle_state = dict
324                .get::<&str, i32>(&"toggle-state")
325                .ok()
326                .flatten()
327                .map(ToggleState::from)
328                .unwrap_or_default();
329
330            menu.toggle_type = dict
331                .get::<&str, &str>(&"toggle-type")
332                .ok()
333                .flatten()
334                .map(ToggleType::from)
335                .unwrap_or_default();
336
337            menu.menu_type = dict
338                .get::<&str, &str>(&"type")
339                .ok()
340                .flatten()
341                .map(MenuType::from)
342                .unwrap_or_default();
343        }
344
345        if let Some(Value::Array(array)) = fields.next() {
346            let mut submenu = vec![];
347            for value in array.iter() {
348                let value = OwnedValue::try_from(value)?;
349                let menu = MenuItem::try_from(&value)?;
350                submenu.push(menu);
351            }
352
353            menu.submenu = submenu;
354        }
355
356        Ok(menu)
357    }
358}
359
360impl TryFrom<PropertiesUpdate<'_>> for Vec<MenuDiff> {
361    type Error = Error;
362
363    fn try_from(value: PropertiesUpdate<'_>) -> Result<Self> {
364        let mut res = HashMap::new();
365
366        for updated in value.updated {
367            let id = updated.id;
368            let update = MenuDiff {
369                id,
370                update: updated.try_into()?,
371                ..Default::default()
372            };
373
374            res.insert(id, update);
375        }
376
377        for removed in value.removed {
378            let update = res.entry(removed.id).or_insert_with(|| MenuDiff {
379                id: removed.id,
380                ..Default::default()
381            });
382
383            update.remove = removed.fields.iter().map(ToString::to_string).collect();
384        }
385
386        Ok(res.into_values().collect())
387    }
388}
389
390impl TryFrom<UpdatedProps<'_>> for MenuItemUpdate {
391    type Error = Error;
392
393    fn try_from(value: UpdatedProps) -> Result<Self> {
394        let dict = value.fields;
395
396        let icon_data = if let Some(arr) = dict
397            .get("icon-data")
398            .map(Value::downcast_ref::<&Array>)
399            .transpose()?
400        {
401            Some(Some(get_icon_data(arr)?))
402        } else {
403            None
404        };
405
406        Ok(Self {
407            label: dict
408                .get("label")
409                .map(|v| v.downcast_ref::<&str>().map(ToString::to_string).ok()),
410
411            enabled: dict
412                .get("enabled")
413                .and_then(|v| Value::downcast_ref::<bool>(v).ok()),
414
415            visible: dict
416                .get("visible")
417                .and_then(|v| Value::downcast_ref::<bool>(v).ok()),
418
419            icon_name: dict
420                .get("icon-name")
421                .map(|v| v.downcast_ref::<&str>().map(ToString::to_string).ok()),
422
423            icon_data,
424
425            toggle_state: dict
426                .get("toggle-state")
427                .and_then(|v| Value::downcast_ref::<i32>(v).ok())
428                .map(ToggleState::from),
429
430            disposition: dict
431                .get("disposition")
432                .and_then(|v| Value::downcast_ref::<&str>(v).ok())
433                .map(Disposition::from),
434        })
435    }
436}
437
438fn get_icon_data(array: &Array) -> Result<Vec<u8>> {
439    array
440        .iter()
441        .map(|v| v.downcast_ref::<u8>().map_err(Into::into))
442        .collect::<Result<Vec<_>>>()
443}