Skip to main content

densa_ui/
popup.rs

1//! Popups: widgets shown above everything else (menus, completion lists, tooltips that take
2//! input), until closed.
3//!
4//! Popups live on a layer of their own, a second root laid out against the viewport: painted
5//! after the widget tree and hit-tested before it. Each is placed next to an [`Anchor`],
6//! moved or flipped to stay inside the viewport.
7
8use std::fmt;
9
10use densa_core::{Point, Rect, Size};
11
12use crate::ui::State;
13use crate::{EventCx, PaintCx, Position, Style, Ui, UiError, Widget, WidgetId};
14
15/// Where a popup goes: next to a rectangle, kept inside the viewport.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Anchor {
18    rect: Rect,
19    side: Side,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum Side {
24    Below,
25    Beside,
26}
27
28impl Anchor {
29    /// Below `rect`, its left edges lined up (a menu under its menu bar title); above `rect`
30    /// if it does not fit below.
31    pub fn below(rect: Rect) -> Self {
32        Self {
33            rect,
34            side: Side::Below,
35        }
36    }
37
38    /// To the right of `rect`, its top edges lined up (a submenu beside its item); to the
39    /// left if it does not fit on the right.
40    pub fn beside(rect: Rect) -> Self {
41        Self {
42            rect,
43            side: Side::Beside,
44        }
45    }
46
47    /// With its top-left corner at `point` (a context menu at the pointer), moved to fit.
48    pub fn at(point: Point) -> Self {
49        Self::below(Rect::new(point.x, point.y, 0.0, 0.0))
50    }
51
52    /// The top-left corner of a popup of `size` in `viewport`.
53    pub(crate) fn place(&self, size: Size, viewport: Size) -> Point {
54        let fit = |start: f32, length: f32, room: f32| start.min(room - length).max(0.0);
55        let rect = self.rect;
56        match self.side {
57            Side::Below => {
58                let below = rect.bottom();
59                let y = if below + size.height <= viewport.height {
60                    below
61                } else if rect.top() - size.height >= 0.0 {
62                    rect.top() - size.height
63                } else {
64                    fit(below, size.height, viewport.height)
65                };
66                Point::new(fit(rect.left(), size.width, viewport.width), y)
67            }
68            Side::Beside => {
69                let right = rect.right();
70                let x = if right + size.width <= viewport.width {
71                    right
72                } else if rect.left() - size.width >= 0.0 {
73                    rect.left() - size.width
74                } else {
75                    fit(right, size.width, viewport.width)
76                };
77                Point::new(x, fit(rect.top(), size.height, viewport.height))
78            }
79        }
80    }
81}
82
83/// An open popup.
84#[derive(Debug, Clone, Copy)]
85pub(crate) struct Popup {
86    pub(crate) id: WidgetId,
87    pub(crate) anchor: Anchor,
88    /// The widget that opened it: a press on it while the popup is open still reaches it
89    /// (a menu bar switching menus).
90    pub(crate) opener: Option<WidgetId>,
91    /// What had focus before it opened, given back when it closes.
92    pub(crate) focus_before: Option<WidgetId>,
93}
94
95/// Widgets added while another widget handles an event, given to the `Ui` once it has.
96#[derive(Default)]
97pub(crate) struct Incoming(pub(crate) Vec<(WidgetId, Box<dyn Widget>)>);
98
99impl fmt::Debug for Incoming {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.debug_list()
102            .entries(self.0.iter().map(|(id, _)| id))
103            .finish()
104    }
105}
106
107impl State {
108    /// Adds `widget` as a popup next to `anchor`, opened by `opener`; it takes focus if it
109    /// accepts it. The widget joins the `Ui` when the current event is over.
110    pub(crate) fn open_popup(
111        &mut self,
112        widget: Box<dyn Widget>,
113        anchor: Anchor,
114        style: Style,
115        opener: Option<WidgetId>,
116    ) -> Result<WidgetId, UiError> {
117        let style = Style {
118            position: Position::Absolute,
119            ..style
120        };
121        let layer = self.layer;
122        let end = self.nodes[layer.slot()].children.len();
123        let id = self.new_node(layer, end, style)?;
124        // Focus as it will be once this event is over: popups closed in the same event (a
125        // menu bar switching menus) have already asked for theirs back.
126        let focus_before = self.requested_focus.unwrap_or(self.focus);
127        if widget.accepts_focus() {
128            self.requested_focus = Some(Some(id));
129        }
130        self.popups.push(Popup {
131            id,
132            anchor,
133            opener,
134            focus_before,
135        });
136        self.incoming.0.push((id, widget));
137        Ok(id)
138    }
139
140    /// Closes popup `id` and every popup opened after it (removed once the current event is
141    /// over); focus inside them goes back to what had it before `id` opened. `false` if `id`
142    /// is not an open popup.
143    pub(crate) fn close_popup(&mut self, id: WidgetId) -> bool {
144        match self.popups.iter().position(|popup| popup.id == id) {
145            Some(index) => {
146                self.close_from(index);
147                true
148            }
149            None => false,
150        }
151    }
152
153    /// Closes every popup from number `index` (oldest first) on.
154    pub(crate) fn close_from(&mut self, index: usize) {
155        let Some(first) = self.popups.get(index).copied() else {
156            return;
157        };
158        let focus_inside = self.focus.is_some_and(|focus| {
159            self.popups[index..]
160                .iter()
161                .any(|popup| self.is_within(focus, popup.id))
162        });
163        for popup in self.popups.drain(index..).rev() {
164            self.closing.push(popup.id);
165        }
166        if focus_inside {
167            self.requested_focus = Some(first.focus_before);
168        }
169        self.needs_paint = true;
170    }
171
172    /// Whether `id` is an open popup.
173    pub(crate) fn is_popup_open(&self, id: WidgetId) -> bool {
174        self.popups.iter().any(|popup| popup.id == id) && self.is_live(id)
175    }
176
177    /// Where popup `id`, of `size`, goes.
178    pub(crate) fn popup_origin(&self, id: WidgetId, size: Size) -> Option<Point> {
179        let popup = self.popups.iter().find(|popup| popup.id == id)?;
180        Some(popup.anchor.place(size, self.viewport()))
181    }
182}
183
184impl Ui {
185    /// Opens `widget` as a popup next to `anchor`, above every other widget, laid out by
186    /// `style` (its position is made absolute: the anchor places it). It takes keyboard focus
187    /// if it accepts it, and gives it back when it closes.
188    ///
189    /// While popups are open, a press or a scroll outside all of them closes them all and
190    /// goes no further; moves still reach the widgets under the pointer.
191    ///
192    /// # Errors
193    /// [`UiError::TooManyWidgets`].
194    pub fn open_popup(
195        &mut self,
196        widget: impl Widget,
197        anchor: Anchor,
198        style: Style,
199    ) -> Result<WidgetId, UiError> {
200        let id = self
201            .state
202            .open_popup(Box::new(widget), anchor, style, None)?;
203        self.settle();
204        Ok(id)
205    }
206
207    /// Closes popup `id` and every popup opened after it. `false` if `id` is not open.
208    pub fn close_popup(&mut self, id: WidgetId) -> bool {
209        let closed = self.state.close_popup(id);
210        self.settle();
211        closed
212    }
213
214    /// Closes every popup (the window lost focus, or was resized).
215    pub fn close_popups(&mut self) {
216        self.state.close_from(0);
217        self.settle();
218    }
219
220    /// The open popups, oldest first.
221    pub fn popups(&self) -> impl Iterator<Item = WidgetId> + '_ {
222        self.state.popups.iter().map(|popup| popup.id)
223    }
224
225    /// Whether `id` is an open popup.
226    pub fn is_popup_open(&self, id: WidgetId) -> bool {
227        self.state.is_popup_open(id)
228    }
229
230    /// Takes in the widgets added during the last event and removes the popups closed in it.
231    pub(crate) fn adopt(&mut self) {
232        let mut incoming = std::mem::take(&mut self.state.incoming.0);
233        for (id, widget) in incoming.drain(..) {
234            if !self.state.is_live(id) {
235                continue;
236            }
237            let slot = id.slot();
238            if slot >= self.widgets.len() {
239                self.widgets.resize_with(slot + 1, || None);
240            }
241            self.widgets[slot] = Some(widget);
242        }
243        // Kept for the next popup, unless one was opened meanwhile.
244        if self.state.incoming.0.is_empty() {
245            self.state.incoming.0 = incoming;
246        }
247        let mut closing = std::mem::take(&mut self.state.closing);
248        for id in closing.drain(..) {
249            self.remove_widget(id);
250        }
251        if self.state.closing.is_empty() {
252            self.state.closing = closing;
253        }
254    }
255}
256
257impl EventCx<'_> {
258    /// Opens `widget` as a popup next to `anchor`, as [`Ui::open_popup`] does, with this
259    /// widget as its opener: a press on this widget while the popup is open closes the
260    /// popup and still reaches this widget. The popup joins the `Ui` once the event is
261    /// handled.
262    ///
263    /// # Errors
264    /// [`UiError::TooManyWidgets`].
265    pub fn open_popup(
266        &mut self,
267        widget: impl Widget,
268        anchor: Anchor,
269        style: Style,
270    ) -> Result<WidgetId, UiError> {
271        let opener = Some(self.id);
272        self.state
273            .open_popup(Box::new(widget), anchor, style, opener)
274    }
275
276    /// Closes popup `id` (this widget, say) and every popup opened after it, once the event
277    /// is handled. `false` if `id` is not open.
278    pub fn close_popup(&mut self, id: WidgetId) -> bool {
279        self.state.close_popup(id)
280    }
281
282    /// Closes every popup once the event is handled (a menu item was chosen).
283    pub fn close_popups(&mut self) {
284        self.state.close_from(0);
285    }
286
287    /// Whether `id` is an open popup.
288    pub fn is_popup_open(&self, id: WidgetId) -> bool {
289        self.state.is_popup_open(id)
290    }
291}
292
293impl PaintCx<'_> {
294    /// Whether `id` is an open popup.
295    pub fn is_popup_open(&self, id: WidgetId) -> bool {
296        self.state.is_popup_open(id)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    const VIEW: Size = Size {
305        width: 800.0,
306        height: 600.0,
307    };
308
309    #[test]
310    fn below_fits_flips_and_clamps() {
311        let size = Size::new(200.0, 100.0);
312        let under = Anchor::below(Rect::new(10.0, 20.0, 80.0, 30.0));
313        assert_eq!(under.place(size, VIEW), Point::new(10.0, 50.0));
314        let low = Anchor::below(Rect::new(700.0, 550.0, 80.0, 30.0));
315        assert_eq!(
316            low.place(size, VIEW),
317            Point::new(600.0, 450.0),
318            "above, moved left"
319        );
320        let tall = Anchor::below(Rect::new(0.0, 50.0, 10.0, 10.0));
321        assert_eq!(
322            tall.place(Size::new(100.0, 580.0), VIEW),
323            Point::new(0.0, 20.0),
324            "neither fits: as low as it can go"
325        );
326    }
327
328    #[test]
329    fn beside_fits_flips_and_clamps() {
330        let size = Size::new(200.0, 100.0);
331        let item = Anchor::beside(Rect::new(100.0, 40.0, 150.0, 24.0));
332        assert_eq!(item.place(size, VIEW), Point::new(250.0, 40.0));
333        let edge = Anchor::beside(Rect::new(500.0, 560.0, 150.0, 24.0));
334        assert_eq!(
335            edge.place(size, VIEW),
336            Point::new(300.0, 500.0),
337            "left, moved up"
338        );
339        let wide = Anchor::beside(Rect::new(100.0, 0.0, 600.0, 24.0));
340        assert_eq!(
341            wide.place(size, VIEW),
342            Point::new(600.0, 0.0),
343            "neither side: pushed in"
344        );
345    }
346
347    #[test]
348    fn at_a_point_is_below_an_empty_rect() {
349        let size = Size::new(200.0, 100.0);
350        assert_eq!(
351            Anchor::at(Point::new(790.0, 590.0)).place(size, VIEW),
352            Point::new(600.0, 490.0)
353        );
354    }
355}