Skip to main content

crabshell_services/tray/
widget.rs

1//! [`Tray`]: one button per tray item, each opening its menu in a popup.
2//! It lives here rather than in `crabshell-ui` because it needs the tray
3//! service, and it is built only from `crabshell-ui`'s widgets.
4
5use std::rc::Rc;
6
7use crabshell_reactive::Memo;
8use crabshell_ui::{
9  Align, Button, Color, Cx, Element, Icon, IconSource, IntoProp, Menu, PopupWindow, Prop, Row,
10  Style, Widget,
11};
12
13use super::{TrayHost, TrayItem};
14use crate::ServicesExt;
15
16/// A menu's popup unless [`Tray::menu_style`] sets another: light text on
17/// dark gray.
18const MENU: Style = Style::new()
19  .background(Color::rgb(0x2b, 0x2b, 0x2b))
20  .color(Color::rgb(0xe6, 0xe6, 0xe6))
21  .radius(6.0)
22  .padding(4, 0);
23
24/// Room around each icon.
25const BUTTON: Style = Style::new().padding(2, 4).radius(4.0);
26
27/// The icons' size unless [`Tray::icon_size`] sets another.
28const ICON_SIZE: u32 = 16;
29
30/// Shown for an item that gives no icon at all.
31const NO_ICON: &str = "image-missing";
32
33type Shared<T> = Rc<dyn Fn() -> T>;
34
35/// The system tray: one icon button per tray item, in the order the items
36/// registered. A left click activates an item, or opens its menu when the
37/// item is only a menu. A right click opens the menu, a middle click is the
38/// item's secondary activation, and scrolling over an icon scrolls it.
39///
40/// A menu opens as a [`PopupWindow`] holding a [`Menu`], next to its icon.
41/// Clicking an entry tells the app and closes the menu.
42///
43/// ```
44/// use crabshell_services::Tray;
45///
46/// let tray = Tray::new().icon_size(18);
47/// ```
48#[derive(Default)]
49pub struct Tray {
50  icon_size: Option<Prop<u32>>,
51  menu_style: Option<Prop<Style>>,
52}
53
54impl Tray {
55  /// A tray with 16 px icons.
56  pub fn new() -> Tray {
57    Tray::default()
58  }
59
60  /// Sets the icons' size in logical pixels.
61  pub fn icon_size(mut self, size: impl IntoProp<u32>) -> Tray {
62    self.icon_size = Some(size.into_prop());
63    self
64  }
65
66  /// Sets the style of each menu's popup: its background, border, padding,
67  /// and the text's color and font. The default is light text on dark
68  /// gray.
69  pub fn menu_style(mut self, style: impl IntoProp<Style>) -> Tray {
70    self.menu_style = Some(style.into_prop());
71    self
72  }
73}
74
75impl Widget for Tray {
76  fn into_element(self: Box<Self>) -> Element {
77    let size: Shared<u32> = self
78      .icon_size
79      .map_or_else(|| Rc::new(|| ICON_SIZE), Prop::shared);
80    let style: Shared<Style> = self
81      .menu_style
82      .map_or_else(|| Rc::new(|| MENU), Prop::shared);
83    // `cx.tray()` needs a context, which a keyed child's view gets, so the
84    // tray is one such child: a row of the items.
85    let tray = Row::new().children_keyed(
86      || vec![()],
87      |_| (),
88      move |cx, _| {
89        let host = cx.tray();
90        let items = host.items();
91        let (size, style) = (Rc::clone(&size), Rc::clone(&style));
92        Row::new().align(Align::Center).children_keyed(
93          move || items.get(),
94          |item| item.id.clone(),
95          move |_, item| item_button(&host, item, &size, &style),
96        )
97      },
98    );
99    Box::new(tray).into_element()
100  }
101}
102
103/// The button for one item.
104fn item_button(
105  host: &TrayHost,
106  item: Memo<TrayItem>,
107  size: &Shared<u32>,
108  style: &Shared<Style>,
109) -> Button {
110  // The id is the key, so it stays for the button's life.
111  let id: Rc<str> = item.with_untracked(|item| item.id.as_str().into());
112  let (icon_size, pick) = (Rc::clone(size), Rc::clone(size));
113  let icon = Icon::new(move || {
114    // shortcut: a widget doesn't know its output's scale, so pixmaps are
115    // picked for twice the size, which a 1x output scales down well; pick
116    // for the real scale once a context knows it.
117    let pixels = pick().saturating_mul(2);
118    let source = item.with(|item| item.icon(pixels));
119    source.unwrap_or_else(|| IconSource::Name(NO_ICON.into()))
120  })
121  .size(move || icon_size());
122  let click = {
123    let (host, id, style) = (host.clone(), Rc::clone(&id), Rc::clone(style));
124    move |cx: &Cx| {
125      if item.with_untracked(|item| item.item_is_menu) {
126        open_menu(cx, &host, &id, &style);
127      } else {
128        host.activate(&id);
129      }
130    }
131  };
132  let right_click = {
133    let (host, id, style) = (host.clone(), Rc::clone(&id), Rc::clone(style));
134    move |cx: &Cx| open_menu(cx, &host, &id, &style)
135  };
136  let (middle, scroll) = (host.clone(), host.clone());
137  let middle_id = Rc::clone(&id);
138  Button::new(icon)
139    .style(BUTTON)
140    .on_click(click)
141    .on_right_click(right_click)
142    .on_middle_click(move |_| middle.secondary_activate(&middle_id))
143    .on_scroll(move |_, steps| scroll.scroll(&id, steps))
144}
145
146/// Opens item `id`'s menu in a popup next to the button `cx` belongs to.
147/// The popup opens at once, so it grabs the pointer with this click's
148/// serial, and stays empty and clear until the app's layout arrives.
149fn open_menu(cx: &Cx, host: &TrayHost, id: &Rc<str>, style: &Shared<Style>) {
150  let Some(service) = host.service.clone() else {
151    return;
152  };
153  let Some(opened) = service.open_menu(id) else {
154    return;
155  };
156  let (host, id, style) = (host.clone(), Rc::clone(id), Rc::clone(style));
157  cx.open_popup(move |cx| {
158    let _following = cx.signal(Following {
159      service: Rc::clone(&service),
160      opened,
161    });
162    let menu = host.menu();
163    // The popup closes once the tray stops following this opening: the
164    // app's menu went, its first read failed, or the app left.
165    let closer = cx.clone();
166    cx.effect(move || {
167      menu.with(|_| ());
168      if !service.follows(opened) {
169        closer.close_popup();
170      }
171    });
172    let shown = {
173      let id = Rc::clone(&id);
174      move || menu.with(|menu| menu.as_ref().is_some_and(|menu| *menu.item == *id))
175    };
176    let entries = {
177      let id = Rc::clone(&id);
178      move || {
179        menu.with(|menu| {
180          let ours = menu.as_ref().filter(|menu| *menu.item == *id);
181          ours.map(|menu| menu.entries.clone()).unwrap_or_default()
182        })
183      }
184    };
185    let (activate, submenu) = (host.clone(), host);
186    // The style is the menu's, not the popup's, so submenus get it too.
187    let menu = Menu::new(entries)
188      .style(move || if shown() { style() } else { Style::new() })
189      .on_activate(move |_, entry| activate.menu_event(&id, entry))
190      .on_open_submenu(move |_, entry| submenu.open_submenu(entry));
191    PopupWindow::new().child(menu)
192  });
193}
194
195/// Kept under a menu's popup. When the popup goes, the tray stops following
196/// the app's menu, unless another opening has taken over since.
197struct Following {
198  service: Rc<super::Service>,
199  opened: u64,
200}
201
202impl Drop for Following {
203  fn drop(&mut self) {
204    self.service.close_menu_opened(self.opened);
205  }
206}