Skip to main content

crabshell_ui/
menu.rs

1//! [`Menu`]: a vertical list of buttons built from menu entries, such as a
2//! tray item's DBusMenu, with separators, checkmarks, radio buttons and
3//! submenus that open as nested popups.
4
5use std::rc::Rc;
6
7use crabshell_reactive::Memo;
8
9use crate::cx::Cx;
10use crate::popup::PopupWindow;
11use crate::prop::{IntoProp, Prop};
12use crate::style::{Color, Style};
13use crate::widgets::{
14  Align, Button, Column, Element, ElementKind, Row, Spacer, Stack, Text, Widget,
15};
16
17/// One entry of a [`Menu`].
18#[derive(Clone, Debug, PartialEq)]
19pub struct MenuEntry {
20  /// The app's id for it, which the menu's handlers get.
21  pub id: i32,
22  /// What it says.
23  pub label: String,
24  /// Whether it is a separator line rather than something to click.
25  pub separator: bool,
26  /// Whether it can be clicked.
27  pub enabled: bool,
28  /// Whether it shows a checkmark or a radio button.
29  pub toggle: Option<MenuToggle>,
30  /// Whether that checkmark or radio button is on.
31  pub checked: bool,
32  /// Whether it opens a submenu. Some apps fill a submenu only when it
33  /// opens, so its `children` may still be empty.
34  pub submenu: bool,
35  /// Its submenu's entries.
36  pub children: Vec<MenuEntry>,
37}
38
39/// What a toggling menu entry shows.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum MenuToggle {
42  /// A checkmark, on or off on its own.
43  Checkmark,
44  /// A radio button, one of a group.
45  Radio,
46}
47
48/// Room around each entry's text.
49const ITEM: Style = Style::new().padding(4, 12);
50/// Laid over an entry under the pointer.
51const HOVER: Style = Style::new().background(Color::rgba(128, 128, 128, 64));
52/// A separator's line: 2 px of gray.
53const SEPARATOR: Style = Style::new()
54  .background(Color::rgba(128, 128, 128, 128))
55  .padding(1, 0);
56/// Laid over an entry that can't be clicked.
57const DISABLED: Style = Style::new().opacity(0.5);
58/// A checkmark or radio button that is off keeps its room.
59const HIDDEN: Style = Style::new().opacity(0.0);
60
61/// A vertical list of buttons, one per entry, as a popup menu shows them.
62///
63/// A click on an entry calls [`on_activate`](Self::on_activate) with its
64/// id, then closes the popups it is in. A click on an entry with a
65/// submenu calls [`on_open_submenu`](Self::on_open_submenu) and opens the
66/// submenu as a popup next to it, with the same styles and handlers.
67/// Disabled entries ignore clicks. Each style takes a value or a closure.
68///
69/// ```
70/// use crabshell_ui::{Menu, MenuEntry, PopupWindow};
71///
72/// let quit = MenuEntry {
73///     id: 1,
74///     label: "Quit".into(),
75///     separator: false,
76///     enabled: true,
77///     toggle: None,
78///     checked: false,
79///     submenu: false,
80///     children: Vec::new(),
81/// };
82/// let popup = PopupWindow::new().child(
83///     Menu::new(vec![quit]).on_activate(|_cx, id| println!("clicked {id}")),
84/// );
85/// ```
86pub struct Menu {
87  entries: Prop<Vec<MenuEntry>>,
88  style: Option<Prop<Style>>,
89  item_style: Option<Prop<Style>>,
90  hover_style: Option<Prop<Style>>,
91  separator_style: Option<Prop<Style>>,
92  on_activate: Option<EntryHandler>,
93  on_open_submenu: Option<EntryHandler>,
94}
95
96impl Menu {
97  /// A menu of `entries`: a list, or a closure that returns one.
98  pub fn new(entries: impl IntoProp<Vec<MenuEntry>>) -> Menu {
99    Menu {
100      entries: entries.into_prop(),
101      style: None,
102      item_style: None,
103      hover_style: None,
104      separator_style: None,
105      on_activate: None,
106      on_open_submenu: None,
107    }
108  }
109
110  /// Sets the style of the menu's box: its background, border and padding.
111  pub fn style(mut self, style: impl IntoProp<Style>) -> Menu {
112    self.style = Some(style.into_prop());
113    self
114  }
115
116  /// Sets each entry's style. The default pads the text 4 px above and
117  /// below and 12 px left and right.
118  pub fn item_style(mut self, style: impl IntoProp<Style>) -> Menu {
119    self.item_style = Some(style.into_prop());
120    self
121  }
122
123  /// Sets the style laid over an entry under the pointer. The default is
124  /// a faint gray background.
125  pub fn hover_style(mut self, style: impl IntoProp<Style>) -> Menu {
126    self.hover_style = Some(style.into_prop());
127    self
128  }
129
130  /// Sets a separator's style. A separator is as tall as the style's
131  /// vertical padding twice; the default is a 2 px gray line.
132  pub fn separator_style(mut self, style: impl IntoProp<Style>) -> Menu {
133    self.separator_style = Some(style.into_prop());
134    self
135  }
136
137  /// Runs `handler` with an entry's id when that entry is clicked.
138  pub fn on_activate(mut self, handler: impl Fn(&Cx, i32) + 'static) -> Menu {
139    self.on_activate = Some(Rc::new(handler));
140    self
141  }
142
143  /// Runs `handler` with an entry's id when its submenu opens, as a tray
144  /// menu tells its app with `open_submenu`.
145  pub fn on_open_submenu(mut self, handler: impl Fn(&Cx, i32) + 'static) -> Menu {
146    self.on_open_submenu = Some(Rc::new(handler));
147    self
148  }
149}
150
151/// What a menu and its submenus share: the styles as closures any of them
152/// can call, and the handlers.
153struct Look {
154  menu: Shared<Style>,
155  item: Shared<Style>,
156  hover: Shared<Style>,
157  separator: Shared<Style>,
158  on_activate: Option<EntryHandler>,
159  on_open_submenu: Option<EntryHandler>,
160}
161
162type Shared<T> = Rc<dyn Fn() -> T>;
163
164/// A handler that gets an entry's id.
165type EntryHandler = Rc<dyn Fn(&Cx, i32)>;
166
167/// A property as a closure that submenus can share, or `default`.
168fn shared<T: Clone + 'static>(prop: Option<Prop<T>>, default: T) -> Shared<T> {
169  prop.map_or_else(|| Rc::new(move || default.clone()), Prop::shared)
170}
171
172impl Widget for Menu {
173  fn into_element(self: Box<Self>) -> Element {
174    let look = Rc::new(Look {
175      menu: shared(self.style, Style::new()),
176      item: shared(self.item_style, ITEM),
177      hover: shared(self.hover_style, HOVER),
178      separator: shared(self.separator_style, SEPARATOR),
179      on_activate: self.on_activate,
180      on_open_submenu: self.on_open_submenu,
181    });
182    menu(look, shared(Some(self.entries), Vec::new()))
183  }
184}
185
186/// An element that is already built, so a function can return either
187/// kind of entry.
188struct Built(Element);
189
190impl Widget for Built {
191  fn into_element(self: Box<Self>) -> Element {
192    self.0
193  }
194}
195
196/// A menu of `entries` in `look`: a column with one child per entry id.
197fn menu(look: Rc<Look>, entries: Shared<Vec<MenuEntry>>) -> Element {
198  let style = Rc::clone(&look.menu);
199  let column = Column::new().style(move || style()).children_keyed(
200    move || entries(),
201    // A separator and an entry differ in kind, so an id that changes
202    // between them gets a new child.
203    |entry: &MenuEntry| (entry.id, entry.separator),
204    move |_cx, entry| entry_view(&look, entry),
205  );
206  let mut element = Box::new(column).into_element();
207  element.name = "Menu";
208  element
209}
210
211/// One entry: a separator line, or a button with its checkmark, label and
212/// submenu arrow. Which of the two is fixed when the entry first appears.
213fn entry_view(look: &Rc<Look>, entry: Memo<MenuEntry>) -> Built {
214  if entry.with_untracked(|entry| entry.separator) {
215    let separator = Rc::clone(&look.separator);
216    let line = Stack::new().style(move || separator());
217    return Built(Box::new(Column::new().padding(3, 0).child(line)).into_element());
218  }
219  let mark = move || match entry.get().toggle {
220    Some(MenuToggle::Radio) => "●".to_owned(),
221    _ => "✓".to_owned(),
222  };
223  let shown = move || {
224    let entry = entry.get();
225    if entry.toggle.is_some() && entry.checked {
226      Style::new()
227    } else {
228      HIDDEN
229    }
230  };
231  let arrow = move || if entry.get().submenu { "▸" } else { "" }.to_owned();
232  // The mark's room fits either symbol, so every label lines up.
233  let marks = Stack::new()
234    .child(Text::new("✓").style(HIDDEN))
235    .child(Text::new("●").style(HIDDEN))
236    .child(Text::new(mark).style(shown));
237  let row = Row::new()
238    .gap(8)
239    .align(Align::Center)
240    .child(marks)
241    .child(Text::new(move || entry.get().label))
242    .child(Spacer::new())
243    .child(Text::new(arrow));
244  // A button centers its child; this row fills it instead, so labels
245  // start at the left and arrows sit at the right.
246  let mut row = Box::new(row).into_element();
247  if let ElementKind::Flex(flex) = &mut row.kind {
248    flex.grow = true;
249  }
250  let (item, hover) = (Rc::clone(&look.item), Rc::clone(&look.hover));
251  let clicked = Rc::clone(look);
252  let button = Button::new(Built(row))
253    .style(move || {
254      let style = item();
255      if entry.get().enabled {
256        style
257      } else {
258        style.merge(DISABLED)
259      }
260    })
261    .hover_style(move || {
262      if entry.get().enabled {
263        hover()
264      } else {
265        Style::new()
266      }
267    })
268    .on_click(move |cx| click(&clicked, cx, entry));
269  Built(Box::new(button).into_element())
270}
271
272/// What a click on `entry` does: opens its submenu, or activates it and
273/// closes the menu.
274fn click(look: &Rc<Look>, cx: &Cx, entry: Memo<MenuEntry>) {
275  let (id, enabled, submenu) =
276    entry.with_untracked(|entry| (entry.id, entry.enabled, entry.submenu));
277  if !enabled {
278    return;
279  }
280  if submenu {
281    if let Some(open) = &look.on_open_submenu {
282      open(cx, id);
283    }
284    let look = Rc::clone(look);
285    let children: Shared<Vec<MenuEntry>> = Rc::new(move || {
286      let children = entry.try_with(|entry| entry.children.clone());
287      children.unwrap_or_default()
288    });
289    cx.open_popup(move |_| PopupWindow::new().child(Built(menu(look, children))));
290    return;
291  }
292  if let Some(activate) = &look.on_activate {
293    activate(cx, id);
294  }
295  // A menu in a panel rather than a popup has nothing to close.
296  cx.close_popups_if_any();
297}