zng_wgt_layer/
popup.rs

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