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