Skip to main content

crabshell_ui/
popup.rs

1//! Popups: [`PopupWindow`], the description of one, and how a widget opens
2//! and closes it with [`Cx::open_popup`] and [`Cx::close_popup`].
3
4use std::cell::RefCell;
5use std::panic::{self, AssertUnwindSafe};
6use std::rc::Rc;
7
8use crabshell_reactive::untrack;
9
10use crate::cx::{Anchor, Cx};
11use crate::geometry::Rect;
12use crate::input::panic_message;
13use crate::prop::{IntoProp, Prop};
14use crate::style::Style;
15use crate::tree::{PopupState, Tree, borrow};
16use crate::widgets::Widget;
17use crate::window::{Anchors, Window};
18
19/// A popup: a window next to the widget that opened it, sized to fit its
20/// one child. Open one with [`Cx::open_popup`].
21///
22/// ```
23/// use crabshell_ui::{Button, PopupWindow, Text};
24///
25/// let button = Button::new(Text::new("open")).on_click(|cx| {
26///     cx.open_popup(|_cx| PopupWindow::new().child(Text::new("hello")))
27/// });
28/// ```
29#[derive(Default)]
30pub struct PopupWindow {
31  style: Option<Prop<Style>>,
32  child: Option<Box<dyn Widget>>,
33  edges: Option<Prop<Anchors>>,
34  gravity: Option<Prop<Anchors>>,
35  adjustment: Option<Prop<Adjust>>,
36}
37
38/// How a popup may move to stay on its output, combined with `|`, as
39/// `xdg_positioner`'s constraint adjustments.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub struct Adjust(u8);
42
43impl Adjust {
44  /// Never moved: it may leave the output.
45  pub const NONE: Adjust = Adjust(0);
46  /// Slid sideways.
47  pub const SLIDE_X: Adjust = Adjust(1);
48  /// Slid up or down.
49  pub const SLIDE_Y: Adjust = Adjust(2);
50  /// Flipped to the other side.
51  pub const FLIP_X: Adjust = Adjust(4);
52  /// Flipped to above or below.
53  pub const FLIP_Y: Adjust = Adjust(8);
54  /// Slid either way.
55  pub const SLIDE: Adjust = Adjust(1 | 2);
56  /// Flipped either way.
57  pub const FLIP: Adjust = Adjust(4 | 8);
58  /// Any of them, flipping first, then sliding. A popup is never resized:
59  /// it is the size of its content.
60  pub const ALL: Adjust = Adjust(15);
61
62  /// Whether every adjustment in `other` is allowed.
63  pub const fn contains(self, other: Adjust) -> bool {
64    self.0 & other.0 == other.0
65  }
66}
67
68impl std::ops::BitOr for Adjust {
69  type Output = Adjust;
70
71  fn bitor(self, other: Adjust) -> Adjust {
72    Adjust(self.0 | other.0)
73  }
74}
75
76/// Where a popup goes next to the widget that opened it. Unset parts
77/// keep the defaults: below the widget from a horizontal bar, beside it
78/// from a vertical bar or another popup, flipped or slid to stay on the
79/// output.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub struct Placement {
82  /// The edge or corner of the widget's rectangle the popup hangs from.
83  pub edges: Option<Anchors>,
84  /// Which way the popup extends from there.
85  pub gravity: Option<Anchors>,
86  /// How it may move to stay on the output.
87  pub adjustment: Option<Adjust>,
88}
89
90impl PopupWindow {
91  /// An empty popup.
92  pub fn new() -> PopupWindow {
93    PopupWindow::default()
94  }
95
96  /// Sets the popup's style. Its color and font are inherited by every
97  /// [`Text`](crate::Text) in it that doesn't set its own.
98  pub fn style(mut self, style: impl IntoProp<Style>) -> PopupWindow {
99    self.style = Some(style.into_prop());
100    self
101  }
102
103  /// Sets the popup's one child. The popup is as large as the child wants
104  /// to be, plus the style's padding.
105  pub fn child(mut self, child: impl Widget) -> PopupWindow {
106    self.child = Some(Box::new(child));
107    self
108  }
109
110  /// Sets the edge or corner of the widget the popup hangs from, such as
111  /// `Edge::Bottom | Edge::Left`; opposite edges cancel. Read when it
112  /// opens, as the compositor places a popup once.
113  pub fn edges(mut self, edges: impl IntoProp<Anchors>) -> PopupWindow {
114    self.edges = Some(edges.into_prop());
115    self
116  }
117
118  /// Sets which way the popup extends from its edges, such as
119  /// `Edge::Bottom | Edge::Right`. Read when it opens.
120  pub fn gravity(mut self, gravity: impl IntoProp<Anchors>) -> PopupWindow {
121    self.gravity = Some(gravity.into_prop());
122    self
123  }
124
125  /// Sets how the popup may move to stay on its output. Read when it
126  /// opens.
127  pub fn adjustment(mut self, adjustment: impl IntoProp<Adjust>) -> PopupWindow {
128    self.adjustment = Some(adjustment.into_prop());
129    self
130  }
131}
132
133impl Cx {
134  /// Opens a popup next to the widget this context belongs to, such as the
135  /// button whose handler got it. `build` describes the popup and gets its
136  /// context: what it creates there goes when the popup does.
137  ///
138  /// The popup belongs to the widget, so it closes when the widget goes,
139  /// as well as on [`close_popup`](Self::close_popup) and when the user
140  /// clicks outside it. If `build` panics, that is logged with the
141  /// widget's path and nothing opens. A context of no widget, such as the
142  /// one `per_output` gives, logs that and opens nothing.
143  pub fn open_popup(&self, build: impl FnOnce(&Cx) -> PopupWindow) {
144    let Some((anchor, (tree, node))) = self
145      .anchor
146      .clone()
147      .and_then(|anchor| anchor.get().map(|found| (anchor, found)))
148    else {
149      return tracing::warn!("open_popup needs a widget's context, such as a handler's");
150    };
151    let opener = {
152      let tree = tree
153        .try_borrow()
154        .expect("invariant: a window's tree is not borrowed while user code runs");
155      tree
156        .node(node)
157        .map(|node| node.path.clone())
158        .zip(Some(tree.output.clone()))
159    };
160    let Some((opener, output)) = opener else {
161      return;
162    };
163    let path: Rc<str> = format!("{opener}/PopupWindow").into();
164    let root = Rc::new(Anchor::default());
165    let cx = self.child().named(path.to_string()).at(Rc::clone(&root));
166    // Untracked, so an effect that opens a popup doesn't rerun, and open
167    // another, when something `build` read changes.
168    let built = panic::catch_unwind(AssertUnwindSafe(|| {
169      untrack(|| {
170        let popup = build(&cx);
171        let child = popup.child.map(|child| child.into_element());
172        (
173          popup.style,
174          child,
175          (popup.edges, popup.gravity, popup.adjustment),
176        )
177      })
178    }));
179    let (style, child, placement) = match built {
180      Ok(built) => built,
181      Err(payload) => {
182        tracing::error!(
183          "{path} panicked while it was built, so it doesn't open: {}",
184          panic_message(payload.as_ref())
185        );
186        cx.owner().dispose();
187        return;
188      }
189    };
190    let owner = cx.owner();
191    let alive = cx.signal(());
192    let popup = Rc::new_cyclic(|this| {
193      let mut tree = Tree::new(this.clone(), "PopupWindow", output, cx, path);
194      tree.popup = Some(PopupState {
195        opener: anchor,
196        close: false,
197        alive,
198        placement: Placement::default(),
199      });
200      RefCell::new(tree)
201    });
202    {
203      let mut tree = borrow(&popup);
204      tree.mount_root(style, child);
205      let (edges, gravity, adjustment) = placement;
206      let root = tree.root;
207      tree.bind_opt(root, edges, "edges", |tree, _, value| {
208        if let Some(popup) = tree.popup.as_mut() {
209          popup.placement.edges = Some(value);
210        }
211      });
212      tree.bind_opt(root, gravity, "gravity", |tree, _, value| {
213        if let Some(popup) = tree.popup.as_mut() {
214          popup.placement.gravity = Some(value);
215        }
216      });
217      tree.bind_opt(root, adjustment, "adjustment", |tree, _, value| {
218        if let Some(popup) = tree.popup.as_mut() {
219          popup.placement.adjustment = Some(value);
220        }
221      });
222    }
223    borrow(&tree).popups.push(Window { tree: popup, owner });
224  }
225
226  /// Closes the popup this context's widget is in, with every popup it
227  /// opened and every popup that opened it, as a menu does once one of
228  /// its entries is clicked. Outside a popup it logs that and does
229  /// nothing.
230  pub fn close_popup(&self) {
231    if !self.close_popups_if_any() {
232      tracing::warn!("close_popup was called outside a popup");
233    }
234  }
235
236  /// [`close_popup`](Self::close_popup), saying whether there was a popup
237  /// to close. It marks this popup and each popup that opened it, one
238  /// tree borrowed at a time; the popups they opened close with them.
239  pub(crate) fn close_popups_if_any(&self) -> bool {
240    let mut next = self
241      .anchor
242      .as_ref()
243      .and_then(|anchor| anchor.get())
244      .map(|(tree, _)| tree);
245    let mut closed = false;
246    while let Some(tree) = next.take() {
247      let mut tree = borrow(&tree);
248      let Some(popup) = tree.popup.as_mut() else {
249        break;
250      };
251      popup.close = true;
252      closed = true;
253      next = popup.opener.get().map(|(tree, _)| tree);
254    }
255    closed
256  }
257}
258
259impl Window {
260  /// Takes the popups this window's widgets opened since the last call.
261  pub fn take_popups(&self) -> Vec<Window> {
262    std::mem::take(&mut borrow(&self.tree).popups)
263  }
264
265  /// For a popup, the rectangle of the widget that opened it, at its
266  /// window's last layout, in that window's logical pixels. The platform
267  /// places the popup next to it. `None` for a panel, once the widget is
268  /// gone, or before its first layout.
269  pub fn anchor(&self) -> Option<Rect> {
270    let opener = Rc::clone(&self.tree.borrow().popup.as_ref()?.opener);
271    let (tree, node) = opener.get()?;
272    let tree = tree.borrow();
273    tree.node(node)?.rect
274  }
275
276  /// Where this popup goes, as its config set it; the defaults for a
277  /// panel.
278  pub fn placement(&self) -> Placement {
279    let tree = self.tree.borrow();
280    tree
281      .popup
282      .as_ref()
283      .map(|popup| popup.placement)
284      .unwrap_or_default()
285  }
286
287  /// Whether this window is a popup.
288  pub fn is_popup(&self) -> bool {
289    self.tree.borrow().popup.is_some()
290  }
291
292  /// Whether this popup should close: [`Cx::close_popup`] asked, or the
293  /// widget that opened it is gone. Always `false` for a panel.
294  pub fn wants_close(&self) -> bool {
295    let tree = self.tree.borrow();
296    tree
297      .popup
298      .as_ref()
299      .is_some_and(|popup| popup.close || popup.alive.try_with(|_| ()).is_none())
300  }
301}