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 ///
128 /// # Panics
129 ///
130 /// Panics if not called from inside an widget.
131 pub fn open(&self, popup: impl IntoUiNode) -> Var<PopupState> {
132 self.open_impl(popup.into_node(), ANCHOR_MODE_VAR.into(), CONTEXT_CAPTURE_VAR.get())
133 }
134
135 /// Open the `popup` using the custom config vars.
136 ///
137 /// If the popup node is not a full widget after init it is upgraded to one. Returns
138 /// a variable that tracks the popup state and ID.
139 ///
140 /// # Panics
141 ///
142 /// Panics if not called from inside an widget.
143 pub fn open_config(
144 &self,
145 popup: impl IntoUiNode,
146 anchor_mode: impl IntoVar<AnchorMode>,
147 context_capture: impl IntoValue<ContextCapture>,
148 ) -> Var<PopupState> {
149 self.open_impl(popup.into_node(), anchor_mode.into_var(), context_capture.into())
150 }
151
152 fn open_impl(&self, mut popup: UiNode, anchor_mode: Var<AnchorMode>, context_capture: ContextCapture) -> Var<PopupState> {
153 let state = var(PopupState::Opening);
154 let mut _close_handle = CommandHandle::dummy();
155
156 let anchor_id = WIDGET.try_id().expect("POPUP service requires a widget context");
157
158 popup = match_widget(
159 popup,
160 clmv!(state, |c, op| match op {
161 UiNodeOp::Init => {
162 c.init();
163
164 if let Some(mut wgt) = c.node().as_widget() {
165 wgt.with_context(WidgetUpdateMode::Bubble, || {
166 let id = WIDGET.id();
167 WIDGET.sub_event_when(&FOCUS_CHANGED_EVENT, move |args| args.is_focus_leave(id));
168 });
169 let id = wgt.id();
170 state.set(PopupState::Open(id));
171 _close_handle = POPUP_CLOSE_CMD.scoped(id).subscribe(true);
172 } else {
173 // not widget after init, generate a widget, but can still become
174 // a widget later, such as a `take_on_init` ArcNode that was already
175 // in use on init, to support `close_delay` in this scenario the not_widget
176 // is wrapped in a node that pumps POPUP_CLOSE_REQUESTED_EVENT to the not_widget
177 // if it is a widget at the time of the event.
178 c.deinit();
179
180 let not_widget = std::mem::replace(c.node(), UiNode::nil());
181 let not_widget = match_node(not_widget, |c, op| match op {
182 UiNodeOp::Init => {
183 WIDGET.sub_event(&FOCUS_CHANGED_EVENT).sub_event(&POPUP_CLOSE_REQUESTED_EVENT);
184 }
185 UiNodeOp::Update { .. } => {
186 POPUP_CLOSE_REQUESTED_EVENT.latest_update(false, |args| {
187 if let Some(mut now_is_widget) = c.node().as_widget() {
188 let now_is_widget = now_is_widget.with_context(WidgetUpdateMode::Ignore, || WIDGET.info().path());
189 if !args.is_in_target(now_is_widget.widget_id()) {
190 // if is not already the request
191 POPUP_CLOSE_REQUESTED_EVENT.notify(PopupCloseRequestedArgs::new(
192 args.timestamp,
193 args.propagation.clone(),
194 now_is_widget,
195 ));
196 }
197 }
198 });
199 }
200 _ => {}
201 });
202
203 *c.node() = not_widget.into_widget();
204
205 c.init();
206 let id = c.node().as_widget().unwrap().id();
207
208 state.set(PopupState::Open(id));
209 _close_handle = POPUP_CLOSE_CMD.scoped(id).subscribe(true);
210 }
211 }
212 UiNodeOp::Deinit => {
213 state.set(PopupState::Closed);
214 _close_handle = CommandHandle::dummy();
215 }
216 UiNodeOp::Update { .. } => {
217 c.node().as_widget().unwrap().with_context(WidgetUpdateMode::Bubble, || {
218 let id = WIDGET.id();
219
220 FOCUS_CHANGED_EVENT.each_update(true, |args| {
221 if args.is_focus_leave(id) && CLOSE_ON_FOCUS_LEAVE_VAR.get() {
222 POPUP.close_id(id);
223 }
224 });
225 POPUP_CLOSE_CMD
226 .scoped(id)
227 .latest_update(true, false, |args| match args.param::<PopupCloseMode>() {
228 Some(s) => match s {
229 PopupCloseMode::Request => POPUP.close_id(id),
230 PopupCloseMode::Force => LAYERS.remove(id),
231 },
232 None => POPUP.close_id(id),
233 });
234 });
235 }
236 _ => {}
237 }),
238 );
239
240 let (filter, over) = match context_capture {
241 ContextCapture::NoCapture => {
242 let filter = CaptureFilter::Include({
243 let mut set = ContextValueSet::new();
244 set.insert(&CLOSE_ON_FOCUS_LEAVE_VAR);
245 set.insert(&ANCHOR_MODE_VAR);
246 set.insert(&CONTEXT_CAPTURE_VAR);
247 set
248 });
249 (filter, false)
250 }
251 ContextCapture::CaptureBlend { filter, over } => (filter, over),
252 };
253 if filter != CaptureFilter::None {
254 popup = with_context_blend(LocalContext::capture_filtered(filter), over, popup);
255 }
256 LAYERS.insert_anchored(LayerIndex::TOP_MOST, anchor_id, anchor_mode, popup);
257
258 state.read_only()
259 }
260
261 /// Close the popup widget when `state` is not already closed.
262 ///
263 /// Notifies [`POPUP_CLOSE_REQUESTED_EVENT`] and then close if no subscriber stops propagation for it.
264 pub fn close(&self, state: &Var<PopupState>) {
265 match state.get() {
266 PopupState::Opening => state
267 .hook(|a| {
268 if let PopupState::Open(id) = a.downcast_value::<PopupState>().unwrap() {
269 POPUP_CLOSE_CMD.scoped(*id).notify_param(PopupCloseMode::Request);
270 }
271 false
272 })
273 .perm(),
274 PopupState::Open(id) => self.close_id(id),
275 PopupState::Closed => {}
276 }
277 }
278
279 /// Close the popup widget when `state` is not already closed, without notifying [`POPUP_CLOSE_REQUESTED_EVENT`] first.
280 pub fn force_close(&self, state: &Var<PopupState>) {
281 match state.get() {
282 PopupState::Opening => state
283 .hook(|a| {
284 if let PopupState::Open(id) = a.downcast_value::<PopupState>().unwrap() {
285 POPUP_CLOSE_CMD.scoped(*id).notify_param(PopupCloseMode::Force);
286 }
287 false
288 })
289 .perm(),
290 PopupState::Open(id) => self.force_close_id(id),
291 PopupState::Closed => {}
292 }
293 }
294
295 /// Close the popup widget by known ID.
296 ///
297 /// The `widget_id` must be the same in the [`PopupState::Open`] returned on open.
298 ///
299 /// You can also use the [`POPUP_CLOSE_CMD`] scoped on the popup to request or force close.
300 pub fn close_id(&self, widget_id: WidgetId) {
301 setup_popup_close_service();
302 if let Some(wgt) = WINDOWS.widget_info(widget_id) {
303 POPUP_CLOSE_REQUESTED_EVENT.notify(PopupCloseRequestedArgs::now(wgt.path()));
304 } else {
305 tracing::debug!("cannot close {widget_id}, not found");
306 }
307 }
308
309 /// Close the popup widget without notifying the request event.
310 pub fn force_close_id(&self, widget_id: WidgetId) {
311 POPUP_CLOSE_CMD.scoped(widget_id).notify_param(PopupCloseMode::Force);
312 }
313
314 /// Gets a read-only var that tracks the anchor widget in a layered widget context.
315 pub fn anchor_id(&self) -> Var<WidgetId> {
316 LAYERS.anchor_id().map(|id| id.expect("POPUP layers are always anchored"))
317 }
318}
319
320/// Identifies the lifetime state of a popup managed by [`POPUP`].
321#[derive(Debug, Clone, Copy, PartialEq)]
322pub enum PopupState {
323 /// Popup will open on the next update.
324 Opening,
325 /// Popup is open and can close itself, or be closed using the ID.
326 Open(WidgetId),
327 /// Popup is closed.
328 Closed,
329}
330
331/// Popup default style.
332#[widget($crate::popup::DefaultStyle)]
333pub struct DefaultStyle(Style);
334impl DefaultStyle {
335 fn widget_intrinsic(&mut self) {
336 widget_set! {
337 self;
338
339 replace = true;
340
341 // same as window
342 background_color = light_dark(rgb(0.9, 0.9, 0.9), rgb(0.1, 0.1, 0.1));
343 drop_shadow = {
344 offset: 2,
345 blur_radius: 2,
346 color: colors::BLACK.with_alpha(50.pct()),
347 };
348 }
349 }
350}
351
352/// Defines if a [`Popup!`] captures the build/instantiation context.
353///
354/// If enabled (default), the popup will build [`with_context_blend`].
355///
356/// [`Popup!`]: struct@Popup
357/// [`with_context_blend`]: zng_wgt::prelude::with_context_blend
358#[derive(Clone, PartialEq, Eq, Debug)]
359pub enum ContextCapture {
360 /// No context capture except the popup configuration context.
361 ///
362 /// The popup will only have the window context as it is open as a layer on the window root.
363 ///
364 /// Note to filter out even the popup config use [`CaptureFilter::None`] instead.
365 NoCapture,
366 /// Build/instantiation context is captured and blended with the node context during all [`UiNodeOp`].
367 ///
368 /// [`UiNodeOp`]: zng_wgt::prelude::UiNodeOp
369 CaptureBlend {
370 /// What context values are captured.
371 filter: CaptureFilter,
372
373 /// If the captured context is blended over or under the node context. If `true` all
374 /// context locals and context vars captured replace any set in the node context, otherwise
375 /// only captures not in the node context are inserted.
376 over: bool,
377 },
378}
379impl ContextCapture {
380 /// Captures all context-vars except those added to the set by `exclude`.
381 ///
382 /// The variables blend over the node context.
383 pub fn context_vars_except(exclude: impl FnOnce(&mut ContextValueSet)) -> Self {
384 ContextCapture::CaptureBlend {
385 filter: CaptureFilter::context_vars_except(exclude),
386 over: true,
387 }
388 }
389}
390impl Default for ContextCapture {
391 /// Captures all context-vars by default, and blend then over the node context.
392 fn default() -> Self {
393 Self::CaptureBlend {
394 filter: CaptureFilter::context_vars(),
395 over: true,
396 }
397 }
398}
399impl_from_and_into_var! {
400 fn from(capture_vars_blend_over: bool) -> ContextCapture {
401 if capture_vars_blend_over {
402 ContextCapture::CaptureBlend {
403 filter: CaptureFilter::ContextVars {
404 exclude: ContextValueSet::new(),
405 },
406 over: true,
407 }
408 } else {
409 ContextCapture::NoCapture
410 }
411 }
412
413 fn from(filter_over: CaptureFilter) -> ContextCapture {
414 ContextCapture::CaptureBlend {
415 filter: filter_over,
416 over: true,
417 }
418 }
419}
420
421event_args! {
422 /// Arguments for [`POPUP_CLOSE_REQUESTED_EVENT`].
423 pub struct PopupCloseRequestedArgs {
424 /// The popup that has close requested.
425 pub popup: WidgetPath,
426
427 ..
428
429 /// If is in [`popup`] path.
430 ///
431 /// [`popup`]: Self::popup
432 fn is_in_target(&self, id: WidgetId) -> bool {
433 self.popup.contains(id)
434 }
435 }
436}
437
438event! {
439 /// Closing popup event.
440 ///
441 /// Requesting [`propagation().stop()`] on this event cancels the popup close.
442 ///
443 /// [`propagation().stop()`]: zng_app::event::EventPropagationHandle::stop
444 pub static POPUP_CLOSE_REQUESTED_EVENT: PopupCloseRequestedArgs;
445}
446event_property! {
447 /// Closing popup event.
448 ///
449 /// Requesting [`propagation().stop()`] on this event cancels the popup close.
450 ///
451 /// [`propagation().stop()`]: zng_app::event::EventPropagationHandle::stop
452 #[property(EVENT)]
453 pub fn on_popup_close_requested<on_pre_popup_close_requested>(
454 child: impl IntoUiNode,
455 handler: Handler<PopupCloseRequestedArgs>,
456 ) -> UiNode {
457 const PRE: bool;
458 EventNodeBuilder::new(POPUP_CLOSE_REQUESTED_EVENT).build::<PRE>(child, handler)
459 }
460}
461
462command! {
463 /// Close the popup.
464 ///
465 /// # Param
466 ///
467 /// The parameter can be [`PopupCloseMode`]. If not set the normal
468 /// [`POPUP.close`] behavior is invoked.
469 ///
470 /// [`POPUP.close`]: POPUP::close
471 pub static POPUP_CLOSE_CMD;
472}
473
474/// Optional parameter for [`POPUP_CLOSE_CMD`].
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
476pub enum PopupCloseMode {
477 /// Calls [`POPUP.close`].
478 ///
479 /// [`POPUP.close`]: POPUP::close
480 #[default]
481 Request,
482 /// Calls [`POPUP.force_close`].
483 ///
484 /// [`POPUP.force_close`]: POPUP::force_close
485 Force,
486}
487
488fn setup_popup_close_service() {
489 app_local! {
490 static POPUP_SETUP: bool = false;
491 }
492
493 if !std::mem::replace(&mut *POPUP_SETUP.write(), true) {
494 POPUP_CLOSE_REQUESTED_EVENT
495 .on_event(
496 false,
497 hn!(|args| {
498 if !args.propagation.is_stopped() {
499 POPUP_CLOSE_CMD.scoped(args.popup.widget_id()).notify_param(PopupCloseMode::Force);
500 }
501 }),
502 )
503 .perm();
504 }
505}
506
507/// Delay awaited before actually closing when popup close is requested.
508///
509/// You can use this delay to await a closing animation for example. This property sets [`is_close_delaying`]
510/// while awaiting the `delay`.
511///
512/// [`is_close_delaying`]: fn@is_close_delaying
513#[property(EVENT, default(Duration::ZERO), widget_impl(Popup, DefaultStyle))]
514pub fn close_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
515 let delay = delay.into_var();
516 let mut timer = None::<DeadlineHandle>;
517
518 let child = match_node(child, move |c, op| match op {
519 UiNodeOp::Init => {
520 let id = WIDGET.id();
521 WIDGET.sub_event_when(&POPUP_CLOSE_REQUESTED_EVENT, move |args| args.popup.widget_id() == id);
522 }
523 UiNodeOp::Deinit => {
524 timer = None;
525 }
526 UiNodeOp::Update { updates } => {
527 c.update(updates);
528 POPUP_CLOSE_REQUESTED_EVENT.each_update(false, |args| {
529 if args.popup.widget_id() != WIDGET.id() {
530 return;
531 }
532
533 if let Some(timer) = &timer {
534 if timer.has_executed() {
535 // allow
536 return;
537 } else {
538 args.propagation.stop();
539 // timer already running.
540 return;
541 }
542 }
543
544 let delay = delay.get();
545 if delay != Duration::ZERO {
546 args.propagation.stop();
547
548 IS_CLOSE_DELAYED_VAR.set(true);
549 let cmd = POPUP_CLOSE_CMD.scoped(args.popup.widget_id());
550 timer = Some(TIMERS.on_deadline(
551 delay,
552 hn_once!(|_| {
553 cmd.notify_param(PopupCloseMode::Force);
554 }),
555 ));
556 }
557 });
558 }
559 _ => {}
560 });
561 with_context_var(child, IS_CLOSE_DELAYED_VAR, var(false))
562}
563
564/// If close was requested for this layered widget and it is just awaiting for the [`close_delay`].
565///
566/// [`close_delay`]: fn@close_delay
567#[property(EVENT+1, widget_impl(Popup, DefaultStyle))]
568pub fn is_close_delaying(child: impl IntoUiNode, state: impl IntoVar<bool>) -> UiNode {
569 bind_state(child, IS_CLOSE_DELAYED_VAR, state)
570}
571
572context_var! {
573 static IS_CLOSE_DELAYED_VAR: bool = false;
574}