zng_wgt_layer/
popup.rs

1//! Popup widget.
2
3use std::time::Duration;
4
5use zng_ext_input::focus::{DirectionalNav, FOCUS_CHANGED_EVENT, FocusScopeOnFocus, TabNav};
6use zng_wgt::{modal_included, prelude::*};
7use zng_wgt_container::Container;
8use zng_wgt_fill::background_color;
9use zng_wgt_filter::drop_shadow;
10use zng_wgt_input::focus::{
11    FocusClickBehavior, FocusableMix, alt_focus_scope, directional_nav, focus_click_behavior, focus_scope_behavior, tab_nav,
12};
13use zng_wgt_style::{Style, StyleMix, impl_style_fn};
14
15use crate::{AnchorMode, AnchorOffset, LAYERS, LayerIndex};
16
17/// An overlay container.
18///
19/// # POPUP
20///
21/// The popup widget is designed to be used as a temporary *flyover* container inserted as a
22/// top-most layer using [`POPUP`]. By default the widget is an [`alt_focus_scope`] that is [`focus_on_init`],
23/// cycles [`directional_nav`] and [`tab_nav`], and has [`FocusClickBehavior::ExitEnabled`]. It also
24/// sets the [`modal_included`] to [`anchor_id`] enabling the popup to be interactive when anchored to modal widgets.
25///
26/// [`alt_focus_scope`]: fn@alt_focus_scope
27/// [`focus_on_init`]: fn@zng_wgt_input::focus::focus_on_init
28/// [`directional_nav`]: fn@directional_nav
29/// [`tab_nav`]: fn@tab_nav
30/// [`modal_included`]: fn@modal_included
31/// [`anchor_id`]: POPUP::anchor_id
32/// [`FocusClickBehavior::ExitEnabled`]: zng_wgt_input::focus::FocusClickBehavior::ExitEnabled
33#[widget($crate::popup::Popup {
34    ($child:expr) => {
35        child = $child;
36    }
37})]
38pub struct Popup(FocusableMix<StyleMix<Container>>);
39impl Popup {
40    fn widget_intrinsic(&mut self) {
41        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
42
43        widget_set! {
44            self;
45
46            alt_focus_scope = true;
47            directional_nav = DirectionalNav::Cycle;
48            tab_nav = TabNav::Cycle;
49            focus_scope_behavior = FocusScopeOnFocus::FirstDescendantIgnoreBounds;
50            focus_click_behavior = FocusClickBehavior::ExitEnabled;
51            focus_on_init = true;
52            modal_included = POPUP.anchor_id();
53        }
54    }
55
56    widget_impl! {
57        /// Popup focus behavior when it or a descendant receives a click.
58        ///
59        /// Is [`FocusClickBehavior::ExitEnabled`] by default.
60        ///
61        /// [`FocusClickBehavior::ExitEnabled`]: zng_wgt_input::focus::FocusClickBehavior::ExitEnabled
62        pub focus_click_behavior(behavior: impl IntoVar<FocusClickBehavior>);
63    }
64}
65impl_style_fn!(Popup, DefaultStyle);
66
67context_var! {
68    /// If popup will close when it no longer contains the focused widget.
69    ///
70    /// Is `true` by default.
71    pub static CLOSE_ON_FOCUS_LEAVE_VAR: bool = true;
72
73    /// Popup anchor mode.
74    ///
75    /// Is `AnchorMode::popup(AnchorOffset::out_bottom())` by default.
76    pub static ANCHOR_MODE_VAR: AnchorMode = AnchorMode::popup(AnchorOffset::out_bottom());
77
78    /// Popup context capture.
79    pub static CONTEXT_CAPTURE_VAR: ContextCapture = ContextCapture::default();
80}
81
82/// Popup behavior when it loses focus.
83///
84/// If `true` the popup will close itself, is `true` by default.
85///
86/// This property must be set on the widget that opens the popup or a parent, not the popup widget itself.
87///
88/// Sets the [`CLOSE_ON_FOCUS_LEAVE_VAR`].
89#[property(CONTEXT, default(CLOSE_ON_FOCUS_LEAVE_VAR))]
90pub fn close_on_focus_leave(child: impl IntoUiNode, close: impl IntoVar<bool>) -> UiNode {
91    with_context_var(child, CLOSE_ON_FOCUS_LEAVE_VAR, close)
92}
93
94/// Defines the popup placement and size for popups open by the widget or descendants.
95///
96/// This property must be set on the widget that opens the popup or a parent, not the popup widget itself.
97///
98/// This property sets the [`ANCHOR_MODE_VAR`].
99#[property(CONTEXT, default(ANCHOR_MODE_VAR))]
100pub fn anchor_mode(child: impl IntoUiNode, mode: impl IntoVar<AnchorMode>) -> UiNode {
101    with_context_var(child, ANCHOR_MODE_VAR, mode)
102}
103
104/// Defines if the popup captures the local context to load in the popup context.
105///
106/// This is enabled by default and lets the popup use context values from the widget
107/// that opens it, not just from the window [`LAYERS`] root where it will actually be inited.
108/// There are potential issues with this, see [`ContextCapture`] for more details.
109///
110/// Note that updates to this property do not affect popups already open, just subsequent popups. This
111/// property must be set on the widget that opens the popup or a parent, not the popup widget itself.
112///
113/// This property sets the [`CONTEXT_CAPTURE_VAR`].
114#[property(CONTEXT, default(CONTEXT_CAPTURE_VAR))]
115pub fn context_capture(child: impl IntoUiNode, capture: impl IntoVar<ContextCapture>) -> UiNode {
116    with_context_var(child, CONTEXT_CAPTURE_VAR, capture)
117}
118
119/// Popup service.
120pub struct POPUP;
121impl POPUP {
122    /// Open the `popup` using the current context config vars.
123    ///
124    /// If the popup node is not a full widget after init it is upgraded to one. Returns
125    /// a variable that tracks the popup state and ID.
126    pub fn open(&self, popup: impl IntoUiNode) -> Var<PopupState> {
127        self.open_impl(popup.into_node(), ANCHOR_MODE_VAR.into(), CONTEXT_CAPTURE_VAR.get())
128    }
129
130    /// Open the `popup` using the custom config vars.
131    ///
132    /// If the popup node is not a full widget after init it is upgraded to one. Returns
133    /// a variable that tracks the popup state and ID.
134    pub fn open_config(
135        &self,
136        popup: impl IntoUiNode,
137        anchor_mode: impl IntoVar<AnchorMode>,
138        context_capture: impl IntoValue<ContextCapture>,
139    ) -> Var<PopupState> {
140        self.open_impl(popup.into_node(), anchor_mode.into_var(), context_capture.into())
141    }
142
143    fn open_impl(&self, mut popup: UiNode, anchor_mode: Var<AnchorMode>, context_capture: ContextCapture) -> Var<PopupState> {
144        let state = var(PopupState::Opening);
145        let mut _close_handle = CommandHandle::dummy();
146
147        let anchor_id = WIDGET.id();
148
149        popup = match_widget(
150            popup,
151            clmv!(state, |c, op| match op {
152                UiNodeOp::Init => {
153                    c.init();
154
155                    if let Some(mut wgt) = c.node().as_widget() {
156                        wgt.with_context(WidgetUpdateMode::Bubble, || {
157                            WIDGET.sub_event(&FOCUS_CHANGED_EVENT);
158                        });
159                        let id = wgt.id();
160                        state.set(PopupState::Open(id));
161                        _close_handle = POPUP_CLOSE_CMD.scoped(id).subscribe(true);
162                    } else {
163                        // not widget after init, generate a widget, but can still become
164                        // a widget later, such as a `take_on_init` ArcNode that was already
165                        // in use on init, to support `close_delay` in this scenario the not_widget
166                        // is wrapped in a node that pumps POPUP_CLOSE_REQUESTED_EVENT to the not_widget
167                        // if it is a widget at the time of the event.
168                        c.deinit();
169
170                        let not_widget = std::mem::replace(c.node(), UiNode::nil());
171                        let not_widget = match_node(not_widget, |c, op| match op {
172                            UiNodeOp::Init => {
173                                WIDGET.sub_event(&FOCUS_CHANGED_EVENT).sub_event(&POPUP_CLOSE_REQUESTED_EVENT);
174                            }
175                            UiNodeOp::Event { update } => {
176                                if let Some(args) = POPUP_CLOSE_REQUESTED_EVENT.on(update)
177                                    && let Some(mut now_is_widget) = c.node().as_widget()
178                                {
179                                    let now_is_widget = now_is_widget.with_context(WidgetUpdateMode::Ignore, || WIDGET.info().path());
180                                    if POPUP_CLOSE_REQUESTED_EVENT.is_subscriber(now_is_widget.widget_id()) {
181                                        // node become widget after init, and it expects POPUP_CLOSE_REQUESTED_EVENT.
182                                        let mut delivery = UpdateDeliveryList::new_any();
183                                        delivery.insert_wgt(&now_is_widget);
184                                        let update = POPUP_CLOSE_REQUESTED_EVENT.new_update_custom(args.clone(), delivery);
185                                        c.event(&update);
186                                    }
187                                }
188                            }
189                            _ => {}
190                        });
191
192                        *c.node() = not_widget.into_widget();
193
194                        c.init();
195                        let id = c.node().as_widget().unwrap().id();
196
197                        state.set(PopupState::Open(id));
198                        _close_handle = POPUP_CLOSE_CMD.scoped(id).subscribe(true);
199                    }
200                }
201                UiNodeOp::Deinit => {
202                    state.set(PopupState::Closed);
203                    _close_handle = CommandHandle::dummy();
204                }
205                UiNodeOp::Event { update } => {
206                    c.node().as_widget().unwrap().with_context(WidgetUpdateMode::Bubble, || {
207                        let id = WIDGET.id();
208
209                        if let Some(args) = FOCUS_CHANGED_EVENT.on(update) {
210                            if args.is_focus_leave(id) && CLOSE_ON_FOCUS_LEAVE_VAR.get() {
211                                POPUP.close_id(id);
212                            }
213                        } else if let Some(args) = POPUP_CLOSE_CMD.scoped(id).on_unhandled(update) {
214                            match args.param::<PopupCloseMode>() {
215                                Some(s) => match s {
216                                    PopupCloseMode::Request => POPUP.close_id(id),
217                                    PopupCloseMode::Force => LAYERS.remove(id),
218                                },
219                                None => POPUP.close_id(id),
220                            }
221                        }
222                    });
223                }
224                _ => {}
225            }),
226        );
227
228        let (filter, over) = match context_capture {
229            ContextCapture::NoCapture => {
230                let filter = CaptureFilter::Include({
231                    let mut set = ContextValueSet::new();
232                    set.insert(&CLOSE_ON_FOCUS_LEAVE_VAR);
233                    set.insert(&ANCHOR_MODE_VAR);
234                    set.insert(&CONTEXT_CAPTURE_VAR);
235                    set
236                });
237                (filter, false)
238            }
239            ContextCapture::CaptureBlend { filter, over } => (filter, over),
240        };
241        if filter != CaptureFilter::None {
242            popup = with_context_blend(LocalContext::capture_filtered(filter), over, popup);
243        }
244        LAYERS.insert_anchored(LayerIndex::TOP_MOST, anchor_id, anchor_mode, popup);
245
246        state.read_only()
247    }
248
249    /// Close the popup widget when `state` is not already closed.
250    ///
251    /// Notifies [`POPUP_CLOSE_REQUESTED_EVENT`] and then close if no subscriber stops propagation for it.
252    pub fn close(&self, state: &Var<PopupState>) {
253        match state.get() {
254            PopupState::Opening => state
255                .hook(|a| {
256                    if let PopupState::Open(id) = a.downcast_value::<PopupState>().unwrap() {
257                        POPUP_CLOSE_CMD.scoped(*id).notify_param(PopupCloseMode::Request);
258                    }
259                    false
260                })
261                .perm(),
262            PopupState::Open(id) => self.close_id(id),
263            PopupState::Closed => {}
264        }
265    }
266
267    /// Close the popup widget when `state` is not already closed, without notifying [`POPUP_CLOSE_REQUESTED_EVENT`] first.
268    pub fn force_close(&self, state: &Var<PopupState>) {
269        match state.get() {
270            PopupState::Opening => state
271                .hook(|a| {
272                    if let PopupState::Open(id) = a.downcast_value::<PopupState>().unwrap() {
273                        POPUP_CLOSE_CMD.scoped(*id).notify_param(PopupCloseMode::Force);
274                    }
275                    false
276                })
277                .perm(),
278            PopupState::Open(id) => self.force_close_id(id),
279            PopupState::Closed => {}
280        }
281    }
282
283    /// Close the popup widget by known ID.
284    ///
285    /// The `widget_id` must be the same in the [`PopupState::Open`] returned on open.
286    ///
287    /// You can also use the [`POPUP_CLOSE_CMD`] scoped on the popup to request or force close.    
288    pub fn close_id(&self, widget_id: WidgetId) {
289        setup_popup_close_service();
290        POPUP_CLOSE_REQUESTED_EVENT.notify(PopupCloseRequestedArgs::now(widget_id));
291    }
292
293    /// Close the popup widget without notifying the request event.
294    pub fn force_close_id(&self, widget_id: WidgetId) {
295        POPUP_CLOSE_CMD.scoped(widget_id).notify_param(PopupCloseMode::Force);
296    }
297
298    /// Gets a read-only var that tracks the anchor widget in a layered widget context.
299    pub fn anchor_id(&self) -> Var<WidgetId> {
300        LAYERS.anchor_id().map(|id| id.expect("POPUP layers are always anchored"))
301    }
302}
303
304/// Identifies the lifetime state of a popup managed by [`POPUP`].
305#[derive(Debug, Clone, Copy, PartialEq)]
306pub enum PopupState {
307    /// Popup will open on the next update.
308    Opening,
309    /// Popup is open and can close itself, or be closed using the ID.
310    Open(WidgetId),
311    /// Popup is closed.
312    Closed,
313}
314
315/// Popup default style.
316#[widget($crate::popup::DefaultStyle)]
317pub struct DefaultStyle(Style);
318impl DefaultStyle {
319    fn widget_intrinsic(&mut self) {
320        widget_set! {
321            self;
322
323            replace = true;
324
325            // same as window
326            background_color = light_dark(rgb(0.9, 0.9, 0.9), rgb(0.1, 0.1, 0.1));
327            drop_shadow = {
328                offset: 2,
329                blur_radius: 2,
330                color: colors::BLACK.with_alpha(50.pct()),
331            };
332        }
333    }
334}
335
336/// Defines if a [`Popup!`] captures the build/instantiation context.
337///
338/// If enabled (default), the popup will build [`with_context_blend`].
339///
340/// [`Popup!`]: struct@Popup
341/// [`with_context_blend`]: zng_wgt::prelude::with_context_blend
342#[derive(Clone, PartialEq, Eq, Debug)]
343pub enum ContextCapture {
344    /// No context capture except the popup configuration context.
345    ///
346    /// The popup will only have the window context as it is open as a layer on the window root.
347    ///
348    /// Note to filter out even the popup config use [`CaptureFilter::None`] instead.
349    NoCapture,
350    /// Build/instantiation context is captured and blended with the node context during all [`UiNodeOp`].
351    ///
352    /// [`UiNodeOp`]: zng_wgt::prelude::UiNodeOp
353    CaptureBlend {
354        /// What context values are captured.
355        filter: CaptureFilter,
356
357        /// If the captured context is blended over or under the node context. If `true` all
358        /// context locals and context vars captured replace any set in the node context, otherwise
359        /// only captures not in the node context are inserted.
360        over: bool,
361    },
362}
363impl Default for ContextCapture {
364    /// Captures all context-vars by default, and blend then over the node context.
365    fn default() -> Self {
366        Self::CaptureBlend {
367            filter: CaptureFilter::context_vars(),
368            over: true,
369        }
370    }
371}
372impl_from_and_into_var! {
373    fn from(capture_vars_blend_over: bool) -> ContextCapture {
374        if capture_vars_blend_over {
375            ContextCapture::CaptureBlend {
376                filter: CaptureFilter::ContextVars {
377                    exclude: ContextValueSet::new(),
378                },
379                over: true,
380            }
381        } else {
382            ContextCapture::NoCapture
383        }
384    }
385
386    fn from(filter_over: CaptureFilter) -> ContextCapture {
387        ContextCapture::CaptureBlend {
388            filter: filter_over,
389            over: true,
390        }
391    }
392}
393
394event_args! {
395    /// Arguments for [`POPUP_CLOSE_REQUESTED_EVENT`].
396    pub struct PopupCloseRequestedArgs {
397        /// The popup that has close requested.
398        pub popup: WidgetId,
399
400        ..
401
402        fn delivery_list(&self, delivery_list: &mut UpdateDeliveryList) {
403            delivery_list.search_widget(self.popup)
404        }
405    }
406}
407
408event! {
409    /// Closing popup event.
410    ///
411    /// Requesting [`propagation().stop()`] on this event cancels the popup close.
412    ///
413    /// [`propagation().stop()`]: zng_app::event::EventPropagationHandle::stop
414    pub static POPUP_CLOSE_REQUESTED_EVENT: PopupCloseRequestedArgs;
415}
416event_property! {
417    /// Closing popup event.
418    ///
419    /// Requesting [`propagation().stop()`] on this event cancels the popup close.
420    ///
421    /// [`propagation().stop()`]: zng_app::event::EventPropagationHandle::stop
422    pub fn popup_close_requested {
423        event: POPUP_CLOSE_REQUESTED_EVENT,
424        args: PopupCloseRequestedArgs,
425    }
426}
427
428command! {
429    /// Close the popup.
430    ///
431    /// # Param
432    ///
433    /// The parameter can be [`PopupCloseMode`]. If not set the normal
434    /// [`POPUP.close`] behavior is invoked.
435    ///
436    /// [`POPUP.close`]: POPUP::close
437    pub static POPUP_CLOSE_CMD;
438}
439
440/// Optional parameter for [`POPUP_CLOSE_CMD`].
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
442pub enum PopupCloseMode {
443    /// Calls [`POPUP.close`].
444    ///
445    /// [`POPUP.close`]: POPUP::close
446    #[default]
447    Request,
448    /// Calls [`POPUP.force_close`].
449    ///
450    /// [`POPUP.force_close`]: POPUP::force_close
451    Force,
452}
453
454fn setup_popup_close_service() {
455    app_local! {
456        static POPUP_SETUP: bool = false;
457    }
458
459    if !std::mem::replace(&mut *POPUP_SETUP.write(), true) {
460        POPUP_CLOSE_REQUESTED_EVENT
461            .on_event(hn!(|args| {
462                if !args.propagation().is_stopped() {
463                    POPUP_CLOSE_CMD.scoped(args.popup).notify_param(PopupCloseMode::Force);
464                }
465            }))
466            .perm();
467    }
468}
469
470/// Delay awaited before actually closing when popup close is requested.
471///
472/// You can use this delay to await a closing animation for example. This property sets [`is_close_delaying`]
473/// while awaiting the `delay`.
474///
475/// [`is_close_delaying`]: fn@is_close_delaying
476#[property(EVENT, default(Duration::ZERO), widget_impl(Popup, DefaultStyle))]
477pub fn close_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
478    let delay = delay.into_var();
479    let mut timer = None::<DeadlineHandle>;
480
481    let child = match_node(child, move |c, op| match op {
482        UiNodeOp::Init => {
483            WIDGET.sub_event(&POPUP_CLOSE_REQUESTED_EVENT);
484        }
485        UiNodeOp::Deinit => {
486            timer = None;
487        }
488        UiNodeOp::Event { update } => {
489            c.event(update);
490            if let Some(args) = POPUP_CLOSE_REQUESTED_EVENT.on_unhandled(update) {
491                if args.popup != WIDGET.id() {
492                    return;
493                }
494
495                if let Some(timer) = &timer {
496                    if timer.has_executed() {
497                        // allow
498                        return;
499                    } else {
500                        args.propagation().stop();
501                        // timer already running.
502                        return;
503                    }
504                }
505
506                let delay = delay.get();
507                if delay != Duration::ZERO {
508                    args.propagation().stop();
509
510                    IS_CLOSE_DELAYED_VAR.set(true);
511                    let cmd = POPUP_CLOSE_CMD.scoped(args.popup);
512                    timer = Some(TIMERS.on_deadline(
513                        delay,
514                        hn_once!(|_| {
515                            cmd.notify_param(PopupCloseMode::Force);
516                        }),
517                    ));
518                }
519            }
520        }
521        _ => {}
522    });
523    with_context_var(child, IS_CLOSE_DELAYED_VAR, var(false))
524}
525
526/// If close was requested for this layered widget and it is just awaiting for the [`close_delay`].
527///
528/// [`close_delay`]: fn@close_delay
529#[property(EVENT+1, widget_impl(Popup, DefaultStyle))]
530pub fn is_close_delaying(child: impl IntoUiNode, state: impl IntoVar<bool>) -> UiNode {
531    bind_state(child, IS_CLOSE_DELAYED_VAR, state)
532}
533
534context_var! {
535    static IS_CLOSE_DELAYED_VAR: bool = false;
536}