Skip to main content

azul_layout/widgets/
popover.rs

1//! Popover widget — wraps an arbitrary anchor [`Dom`] and shows an
2//! absolutely-positioned floating panel holding arbitrary `content: Dom` when
3//! the anchor is **clicked** (toggling open/closed). A click-triggered sibling
4//! of [`crate::widgets::tooltip::Tooltip`] (which is hover-triggered and
5//! text-only): the CSS show/hide popup mechanism is identical, but the panel
6//! holds a whole [`Dom`] and is toggled by an internal click handler that flips
7//! a [`PopoverState`].
8//!
9//! Structure: a `position: relative` wrapper containing a clickable *trigger*
10//! (which holds the anchor) followed by the absolutely-positioned *content*
11//! panel, hidden by default (`display: none`). Clicking the trigger flips
12//! `open`, invokes the optional user `on_toggle(state)`, and shows/hides the
13//! panel via `set_css_property(display)` (mirroring the live-restyle pattern of
14//! check_box / accordion).
15//!
16//! TODO2: like [`Tooltip`], this is a CSS simplification of a "real" floating
17//! popover. The panel is placed at a fixed offset below the trigger (it does not
18//! measure the trigger's height, flip when near a screen edge, escape an
19//! `overflow: hidden` ancestor, or raise its z-order — it relies on being the
20//! later sibling to paint on top). There is also no "click-outside to dismiss"
21//! and no `Escape` handling — clicking the trigger again is the only way to
22//! close it (clicking *inside* the panel does not close it, since the handler is
23//! on the trigger, not the wrapper). A future revision could route through the
24//! window-popup / menu popup path for true screen-anchored positioning and
25//! outside-click dismissal once that is runtime-verifiable.
26//!
27//! Key types: [`Popover`], [`PopoverState`], [`PopoverOnToggle`].
28
29use azul_core::{
30    callbacks::{CoreCallbackData, Update},
31    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
32    refany::RefAny,
33};
34use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
35use azul_css::{
36    props::{
37        basic::{color::ColorU, *},
38        layout::{LayoutDisplay, LayoutPosition, LayoutFlexGrow, LayoutTop, LayoutLeft, LayoutMinWidth, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
39        property::{CssProperty, *},
40        style::{StyleCursor, StyleBackgroundContentVec, StyleBackgroundContent, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius},
41    },
42    impl_option_inner, AzString,
43};
44
45use crate::callbacks::{Callback, CallbackInfo};
46
47static POPOVER_WRAPPER_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-popover"))];
49static POPOVER_TRIGGER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
50    "__azul-native-popover-trigger",
51))];
52static POPOVER_CONTENT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
53    "__azul-native-popover-content",
54))];
55
56// ---- layout (logical px) ----
57/// Fixed vertical offset of the panel below the wrapper's top edge. A
58/// simplification — see the module-level `TODO2`.
59const CONTENT_OFFSET_Y: isize = 32;
60/// Minimum width of the floating panel.
61const CONTENT_MIN_WIDTH: isize = 160;
62const CONTENT_RADIUS: isize = 6;
63
64// ---- colours ----
65/// Panel background (white).
66const CONTENT_BG_COLOR: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
67/// Panel border (#cccccc).
68const CONTENT_BORDER_COLOR: ColorU = ColorU { r: 204, g: 204, b: 204, a: 255 };
69
70/// Callback function type invoked when a popover is toggled. The [`PopoverState`]
71/// carries the *new* open/closed value.
72pub type PopoverOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, PopoverState) -> Update;
73impl_widget_callback!(
74    PopoverOnToggle,
75    OptionPopoverOnToggle,
76    PopoverOnToggleCallback,
77    PopoverOnToggleCallbackType
78);
79
80azul_core::impl_managed_callback! {
81    wrapper:        PopoverOnToggleCallback,
82    info_ty:        CallbackInfo,
83    return_ty:      Update,
84    default_ret:    Update::DoNothing,
85    invoker_static: POPOVER_ON_TOGGLE_INVOKER,
86    invoker_ty:     AzPopoverOnToggleCallbackInvoker,
87    thunk_fn:       az_popover_on_toggle_callback_thunk,
88    setter_fn:      AzApp_setPopoverOnToggleCallbackInvoker,
89    from_handle_fn: AzPopoverOnToggleCallback_createFromHostHandle,
90    extra_args:     [ state: PopoverState ],
91}
92
93/// A click-triggered floating panel anchored to an arbitrary [`Dom`].
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[repr(C)]
96pub struct Popover {
97    /// Runtime state (`open`) plus the optional toggle callback.
98    pub popover_state: PopoverStateWrapper,
99    /// The element that, when clicked, toggles the panel.
100    pub anchor: Dom,
101    /// The content shown inside the floating panel.
102    pub content: Dom,
103    /// Style of the positioning wrapper around the trigger + panel.
104    pub wrapper_style: CssPropertyWithConditionsVec,
105    /// Style of the floating content panel (includes its current `display`).
106    pub content_style: CssPropertyWithConditionsVec,
107}
108
109#[derive(Debug, Default, Clone, PartialEq, Eq)]
110#[repr(C)]
111pub struct PopoverStateWrapper {
112    /// Whether the panel is currently open.
113    pub inner: PopoverState,
114    /// Optional: function to call when the popover is toggled.
115    pub on_toggle: OptionPopoverOnToggle,
116}
117
118/// The open/closed state of a [`Popover`].
119#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
120#[repr(C)]
121pub struct PopoverState {
122    /// `true` = panel shown, `false` (default) = panel hidden.
123    pub open: bool,
124}
125
126/// Wrapper around the trigger + panel: an inline-block positioning context so
127/// the absolutely-positioned panel is placed relative to it.
128static POPOVER_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
129    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
130    CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
131    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
132];
133
134/// The clickable trigger holding the anchor.
135static POPOVER_TRIGGER_STYLE: &[CssPropertyWithConditions] = &[
136    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
137    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
138    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
139];
140
141/// Builds the floating-panel style. Only the `display` (open vs closed) differs
142/// between states; all the positioning/visual props are present in both so the
143/// runtime `set_css_property(display)` toggle has everything it needs (mirroring
144/// the accordion body-style approach).
145fn build_content_style(open: bool) -> CssPropertyWithConditionsVec {
146    let display = if open {
147        LayoutDisplay::Block
148    } else {
149        LayoutDisplay::None
150    };
151    let bg_vec = StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(
152        CONTENT_BG_COLOR
153    )]);
154    CssPropertyWithConditionsVec::from_vec(alloc::vec![
155        CssPropertyWithConditions::simple(CssProperty::const_display(display)),
156        CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
157        CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(
158            CONTENT_OFFSET_Y,
159        ))),
160        CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
161        CssPropertyWithConditions::simple(CssProperty::const_min_width(LayoutMinWidth::const_px(
162            CONTENT_MIN_WIDTH,
163        ))),
164        // padding: 8px
165        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
166            8,
167        ))),
168        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
169            LayoutPaddingBottom::const_px(8),
170        )),
171        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
172            LayoutPaddingLeft::const_px(8),
173        )),
174        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
175            LayoutPaddingRight::const_px(8),
176        )),
177        // border: 1px solid #cccccc
178        CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
179            LayoutBorderTopWidth::const_px(1),
180        )),
181        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
182            LayoutBorderBottomWidth::const_px(1),
183        )),
184        CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
185            LayoutBorderLeftWidth::const_px(1),
186        )),
187        CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
188            LayoutBorderRightWidth::const_px(1),
189        )),
190        CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
191            inner: BorderStyle::Solid,
192        })),
193        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
194            StyleBorderBottomStyle {
195                inner: BorderStyle::Solid,
196            },
197        )),
198        CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
199            inner: BorderStyle::Solid,
200        })),
201        CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
202            StyleBorderRightStyle {
203                inner: BorderStyle::Solid,
204            },
205        )),
206        CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
207            inner: CONTENT_BORDER_COLOR,
208        })),
209        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
210            StyleBorderBottomColor {
211                inner: CONTENT_BORDER_COLOR,
212            },
213        )),
214        CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
215            inner: CONTENT_BORDER_COLOR,
216        })),
217        CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
218            StyleBorderRightColor {
219                inner: CONTENT_BORDER_COLOR,
220            },
221        )),
222        // border-radius: 6px
223        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
224            StyleBorderTopLeftRadius::const_px(CONTENT_RADIUS),
225        )),
226        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
227            StyleBorderTopRightRadius::const_px(CONTENT_RADIUS),
228        )),
229        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
230            StyleBorderBottomLeftRadius::const_px(CONTENT_RADIUS),
231        )),
232        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
233            StyleBorderBottomRightRadius::const_px(CONTENT_RADIUS),
234        )),
235        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
236    ])
237}
238
239impl Popover {
240    /// Creates a popover whose `anchor`, when clicked, toggles a panel holding
241    /// `content`. The panel starts closed.
242    #[must_use] pub fn new(anchor: Dom, content: Dom) -> Self {
243        Self {
244            popover_state: PopoverStateWrapper::default(),
245            anchor,
246            content,
247            wrapper_style: CssPropertyWithConditionsVec::from_const_slice(POPOVER_WRAPPER_STYLE),
248            content_style: build_content_style(false),
249        }
250    }
251
252    /// Sets whether the panel starts open, recomputing the panel style.
253    #[inline]
254    pub fn set_open(&mut self, open: bool) {
255        self.popover_state.inner.open = open;
256        self.content_style = build_content_style(open);
257    }
258
259    /// Builder-style setter for the initial open state.
260    #[inline]
261    #[must_use] pub fn with_open(mut self, open: bool) -> Self {
262        self.set_open(open);
263        self
264    }
265
266    /// Sets the toggle callback (invoked with the new state on every toggle).
267    #[inline]
268    pub fn set_on_toggle<C: Into<PopoverOnToggleCallback>>(&mut self, data: RefAny, on_toggle: C) {
269        self.popover_state.on_toggle = Some(PopoverOnToggle {
270            callback: on_toggle.into(),
271            refany: data,
272        })
273        .into();
274    }
275
276    /// Builder-style setter for the toggle callback.
277    #[inline]
278    #[must_use] pub fn with_on_toggle<C: Into<PopoverOnToggleCallback>>(
279        mut self,
280        data: RefAny,
281        on_toggle: C,
282    ) -> Self {
283        self.set_on_toggle(data, on_toggle);
284        self
285    }
286
287    /// Replaces `self` with a default (empty) popover and returns the original.
288    #[inline]
289    #[must_use] pub fn swap_with_default(&mut self) -> Self {
290        let mut s = Self::new(Dom::default(), Dom::default());
291        core::mem::swap(&mut s, self);
292        s
293    }
294
295    /// Renders the popover into a [`Dom`] subtree with the `__azul-native-popover`
296    /// class.
297    #[must_use] pub fn dom(self) -> Dom {
298        use azul_core::{callbacks::CoreCallback, dom::{EventFilter, HoverEventFilter}, refany::OptionRefAny};
299
300        // The trigger carries the click handler + the shared state. Clicking the
301        // anchor (a descendant of the trigger) bubbles up to it (currentTarget
302        // semantics — see `radio_group`), so `get_hit_node()` resolves to the
303        // trigger regardless of what inside the anchor was clicked. Clicking the
304        // panel does NOT toggle, since the panel is a sibling, not a child.
305        let trigger = Dom::create_div()
306            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_TRIGGER_CLASS))
307            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(POPOVER_TRIGGER_STYLE))
308            .with_tab_index(TabIndex::Auto)
309            .with_callbacks(
310                vec![CoreCallbackData {
311                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
312                    callback: CoreCallback {
313                        cb: on_popover_toggle as usize,
314                        ctx: OptionRefAny::None,
315                    },
316                    refany: RefAny::new(self.popover_state),
317                }]
318                .into(),
319            )
320            .with_children(vec![self.anchor].into());
321
322        let content = Dom::create_div()
323            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_CONTENT_CLASS))
324            .with_css_props(self.content_style)
325            .with_children(vec![self.content].into());
326
327        Dom::create_div()
328            .with_ids_and_classes(IdOrClassVec::from_const_slice(POPOVER_WRAPPER_CLASS))
329            .with_css_props(self.wrapper_style)
330            // children: [trigger, content] — the panel is the trigger's next sibling.
331            .with_children(vec![trigger, content].into())
332    }
333}
334
335impl Default for Popover {
336    fn default() -> Self {
337        Self::new(Dom::default(), Dom::default())
338    }
339}
340
341/// Trigger click handler. The hit node is the trigger (the callback-bearing
342/// node, per `currentTarget` semantics — see `radio_group`); its next sibling is
343/// the content panel. Flips `open`, invokes the optional user callback with the
344/// new state, then shows/hides the panel via `display`.
345extern "C" fn on_popover_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
346    let trigger = info.get_hit_node();
347    let Some(content) = info.get_next_sibling(trigger) else {
348        return Update::DoNothing;
349    };
350
351    let (now_open, result) = {
352        let Some(mut pop) = data.downcast_mut::<PopoverStateWrapper>() else {
353            return Update::DoNothing;
354        };
355        pop.inner.open = !pop.inner.open;
356        let now_open = pop.inner.open;
357        let inner = pop.inner;
358        let pop = &mut *pop;
359        let result = match pop.on_toggle.as_mut() {
360            Some(PopoverOnToggle { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
361            None => Update::DoNothing,
362        };
363        (now_open, result)
364    };
365
366    // TODO2: shows/hides the panel by toggling `display` via set_css_property.
367    // This follows the proven live-restyle pattern of accordion/check_box; the
368    // display:none/block relayout itself is not GUI-verified in this build.
369    let display = if now_open {
370        LayoutDisplay::Block
371    } else {
372        LayoutDisplay::None
373    };
374    info.set_css_property(content, CssProperty::const_display(display));
375
376    result
377}
378
379impl From<Popover> for Dom {
380    fn from(p: Popover) -> Self {
381        p.dom()
382    }
383}