Skip to main content

tui_lipan/widgets/popover/
mod.rs

1pub(crate) mod layout;
2pub(crate) mod node;
3pub(crate) mod reconcile;
4
5pub use node::PopoverNode;
6pub(crate) use reconcile::*;
7
8use crate::callback::Callback;
9use crate::core::element::{Element, ElementKind, IntoElement};
10use crate::overlay::OverlayScope;
11use crate::style::Length;
12
13/// Popover placement relative to the trigger.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
15pub enum PopoverPlacement {
16    /// Below the trigger, left-aligned.
17    #[default]
18    BelowStart,
19    /// Below the trigger, centered.
20    BelowCenter,
21    /// Below the trigger, right-aligned.
22    BelowEnd,
23    /// Above the trigger, left-aligned.
24    AboveStart,
25    /// Above the trigger, centered.
26    AboveCenter,
27    /// Above the trigger, right-aligned.
28    AboveEnd,
29    /// Right of the trigger, top-aligned.
30    RightStart,
31    /// Right of the trigger, centered.
32    RightCenter,
33    /// Right of the trigger, bottom-aligned.
34    RightEnd,
35    /// Left of the trigger, top-aligned.
36    LeftStart,
37    /// Left of the trigger, centered.
38    LeftCenter,
39    /// Left of the trigger, bottom-aligned.
40    LeftEnd,
41}
42
43/// Signed offset applied to the popover position.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
45pub struct PopoverOffset {
46    /// Horizontal offset in cells.
47    pub x: i16,
48    /// Vertical offset in cells.
49    pub y: i16,
50}
51
52impl PopoverOffset {
53    /// Zero offset.
54    pub const ZERO: Self = Self { x: 0, y: 0 };
55}
56
57impl From<(i16, i16)> for PopoverOffset {
58    fn from(value: (i16, i16)) -> Self {
59        Self {
60            x: value.0,
61            y: value.1,
62        }
63    }
64}
65
66/// A popover widget.
67#[derive(Clone)]
68pub struct Popover {
69    pub(crate) trigger: Box<Element>,
70    pub(crate) content: Box<Element>,
71    pub(crate) on_close: Option<Callback<()>>,
72    pub(crate) open: bool,
73    pub(crate) scope: OverlayScope,
74    pub(crate) placement: PopoverPlacement,
75    pub(crate) offset: PopoverOffset,
76    pub(crate) clamp: bool,
77    pub(crate) auto_flip: bool,
78    pub(crate) min_trigger_width: bool,
79    pub(crate) fit_trigger_width: bool,
80    pub(crate) max_width: Option<Length>,
81    pub(crate) anchor: Option<(u16, u16)>,
82    pub(crate) capture_focus: bool,
83    pub(crate) auto_focus: bool,
84}
85
86impl Default for Popover {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Popover {
93    /// Create a new popover.
94    pub fn new() -> Self {
95        Self {
96            trigger: Box::new(crate::widgets::Spacer::new().into()),
97            content: Box::new(crate::widgets::Spacer::new().into()),
98            on_close: None,
99            open: false,
100            scope: OverlayScope::RootPortal,
101            placement: PopoverPlacement::default(),
102            offset: PopoverOffset::ZERO,
103            clamp: true,
104            auto_flip: true,
105            min_trigger_width: true,
106            fit_trigger_width: false,
107            max_width: None,
108            anchor: None,
109            capture_focus: true,
110            auto_focus: true,
111        }
112    }
113
114    /// Set the trigger element.
115    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
116        self.trigger = Box::new(trigger.into());
117        self
118    }
119
120    /// Set the content element.
121    pub fn content(mut self, content: impl IntoElement) -> Self {
122        self.content = Box::new(content.into());
123        self
124    }
125
126    /// Set open state.
127    pub fn open(mut self, open: bool) -> Self {
128        self.open = open;
129        self
130    }
131
132    /// Set overlay scope (portal vs local rendering).
133    pub fn scope(mut self, scope: OverlayScope) -> Self {
134        self.scope = scope;
135        self
136    }
137
138    /// Control whether an open root-portal popover captures and traps focus.
139    ///
140    /// Disable this for passive overlays such as autocomplete suggestions that
141    /// must render through the root portal while their trigger retains keyboard focus.
142    /// This has no effect on local popovers.
143    pub fn capture_focus(mut self, capture_focus: bool) -> Self {
144        self.capture_focus = capture_focus;
145        self
146    }
147
148    /// Control whether an open root-portal popover focuses its first focusable descendant.
149    ///
150    /// Disabling this keeps keyboard and pointer capture active while focus is suspended.
151    pub fn auto_focus(mut self, auto_focus: bool) -> Self {
152        self.auto_focus = auto_focus;
153        self
154    }
155
156    /// Set popover placement relative to the trigger.
157    pub fn placement(mut self, placement: PopoverPlacement) -> Self {
158        self.placement = placement;
159        self
160    }
161
162    /// Set popover offset.
163    pub fn offset(mut self, offset: impl Into<PopoverOffset>) -> Self {
164        self.offset = offset.into();
165        self
166    }
167
168    /// Clamp the popover to the viewport bounds.
169    pub fn clamp(mut self, clamp: bool) -> Self {
170        self.clamp = clamp;
171        self
172    }
173
174    /// Automatically flip placement when it overflows the viewport.
175    pub fn auto_flip(mut self, auto_flip: bool) -> Self {
176        self.auto_flip = auto_flip;
177        self
178    }
179
180    /// Ensure the popover is at least as wide as the trigger.
181    ///
182    /// This is enabled by default. The popover may still grow wider when content
183    /// requires more space, unless capped by [`Self::max_width`] or forced by
184    /// [`Self::fit_trigger_width`].
185    pub fn min_trigger_width(mut self, min_trigger_width: bool) -> Self {
186        self.min_trigger_width = min_trigger_width;
187        self
188    }
189
190    /// Force popover width to exactly match trigger width.
191    pub fn fit_trigger_width(mut self, fit_trigger_width: bool) -> Self {
192        self.fit_trigger_width = fit_trigger_width;
193        self
194    }
195
196    /// Cap the resolved popover width.
197    ///
198    /// Percent values resolve against the active overlay bounds. The cap applies
199    /// after trigger-width fitting/minimums, so it can intentionally make the
200    /// popover narrower than its trigger.
201    pub fn max_width(mut self, max_width: Length) -> Self {
202        self.max_width = Some(max_width);
203        self
204    }
205
206    /// Anchor the popover to an absolute position (content coordinates).
207    pub fn anchor(mut self, anchor: Option<(u16, u16)>) -> Self {
208        self.anchor = anchor;
209        self
210    }
211
212    /// Set on-close callback.
213    pub fn on_close(mut self, cb: Callback<()>) -> Self {
214        self.on_close = Some(cb);
215        self
216    }
217}
218
219impl From<Popover> for Element {
220    fn from(popover: Popover) -> Self {
221        Element::new(ElementKind::Popover(popover)).with_layout(crate::style::LayoutConstraints {
222            min_w: crate::style::Length::Px(0),
223            min_h: crate::style::Length::Px(0),
224            ..Default::default()
225        })
226    }
227}
228
229impl crate::layout::hash::LayoutHash for Popover {
230    fn layout_hash(
231        &self,
232        hasher: &mut impl std::hash::Hasher,
233        recurse: &dyn Fn(&Element) -> Option<u64>,
234    ) -> Option<()> {
235        use std::hash::Hash;
236        self.open.hash(hasher);
237        self.scope.hash(hasher);
238        self.placement.hash(hasher);
239        self.offset.hash(hasher);
240        self.min_trigger_width.hash(hasher);
241        self.fit_trigger_width.hash(hasher);
242        self.max_width.hash(hasher);
243        self.anchor.hash(hasher);
244        self.capture_focus.hash(hasher);
245        self.auto_focus.hash(hasher);
246        recurse(self.trigger.as_ref())?.hash(hasher);
247        Some(())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::core::element::ElementKind;
255
256    #[test]
257    fn popover_defaults_to_root_portal_scope() {
258        let element: Element = Popover::new().into();
259
260        let ElementKind::Popover(popover) = element.kind else {
261            panic!("expected popover element");
262        };
263
264        assert_eq!(popover.scope, OverlayScope::RootPortal);
265        assert!(popover.capture_focus);
266    }
267
268    #[test]
269    fn popover_scope_builder_updates_scope() {
270        let element: Element = Popover::new().scope(OverlayScope::Local).into();
271
272        let ElementKind::Popover(popover) = element.kind else {
273            panic!("expected popover element");
274        };
275
276        assert_eq!(popover.scope, OverlayScope::Local);
277    }
278
279    #[test]
280    fn popover_capture_focus_builder_updates_capture() {
281        let element: Element = Popover::new().capture_focus(false).into();
282
283        let ElementKind::Popover(popover) = element.kind else {
284            panic!("expected popover element");
285        };
286
287        assert!(!popover.capture_focus);
288    }
289}