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