zng_wgt_menu/
popup.rs

1//! Sub-menu popup widget and properties.
2
3use std::sync::Arc;
4
5use colors::BASE_COLOR_VAR;
6use zng_ext_input::{
7    focus::{FOCUS, FOCUS_CHANGED_EVENT, WidgetInfoFocusExt as _},
8    keyboard::{KEY_INPUT_EVENT, Key, KeyState},
9};
10use zng_layout::unit::Orientation2D;
11use zng_wgt::{base_color, border, prelude::*};
12use zng_wgt_fill::background_color;
13use zng_wgt_input::pointer_capture::{CaptureMode, capture_pointer_on_init};
14use zng_wgt_layer::popup::{POPUP, POPUP_CLOSE_CMD, POPUP_CLOSE_REQUESTED_EVENT, PopupCloseMode};
15use zng_wgt_stack::Stack;
16use zng_wgt_style::{impl_style_fn, style_fn};
17
18use super::sub::{HOVER_OPEN_DELAY_VAR, SubMenuWidgetInfoExt};
19
20/// Sub-menu popup.
21#[widget($crate::popup::SubMenuPopup)]
22pub struct SubMenuPopup(zng_wgt_layer::popup::Popup);
23impl SubMenuPopup {
24    fn widget_intrinsic(&mut self) {
25        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
26        widget_set! {
27            self;
28            style_base_fn = style_fn!(|_| DefaultStyle!());
29
30            // Supports press-and-drag to click gesture:
31            //
32            // - Sub-menu is `capture_pointer = true`.
33            // - Menu items set`click_mode = release`.
34            //
35            // So the user can press to open the menu, then drag over an item and release to click it.
36            capture_pointer_on_init = CaptureMode::Subtree;
37        }
38
39        self.widget_builder().push_build_action(|wgt| {
40            let id = wgt.capture_value::<WidgetId>(property_id!(Self::parent_id));
41            let children = wgt
42                .capture_property(property_id!(Self::children))
43                .map(|p| p.args.ui_node_list(0).clone())
44                .unwrap_or_else(|| ArcNodeList::new(ui_vec![].boxed()));
45
46            wgt.set_child(sub_menu_popup_node(children, id));
47        });
48    }
49}
50impl_style_fn!(SubMenuPopup);
51
52/// Sub-menu items.
53#[property(CHILD, capture, default(ui_vec![]), widget_impl(SubMenuPopup))]
54pub fn children(children: impl UiNodeList) {}
55
56/// Parent sub-menu ID.
57#[property(CONTEXT, capture, widget_impl(SubMenuPopup))]
58pub fn parent_id(submenu_id: impl IntoValue<WidgetId>) {}
59
60context_var! {
61    /// Defines the layout widget for [`SubMenuPopup!`].
62    ///
63    /// Is [`default_panel_fn`] by default.
64    ///
65    /// [`SubMenuPopup!`]: struct@SubMenuPopup
66    pub static PANEL_FN_VAR: WidgetFn<zng_wgt_panel::PanelArgs> = WidgetFn::new(default_panel_fn);
67}
68
69/// Widget function that generates the sub-menu popup layout.
70///
71/// This property sets [`PANEL_FN_VAR`].
72#[property(CONTEXT, default(PANEL_FN_VAR), widget_impl(SubMenuPopup))]
73pub fn panel_fn(child: impl UiNode, panel: impl IntoVar<WidgetFn<zng_wgt_panel::PanelArgs>>) -> impl UiNode {
74    with_context_var(child, PANEL_FN_VAR, panel)
75}
76
77/// Sub-menu popup default style.
78#[widget($crate::popup::DefaultStyle)]
79pub struct DefaultStyle(zng_wgt_layer::popup::DefaultStyle);
80impl DefaultStyle {
81    fn widget_intrinsic(&mut self) {
82        widget_set! {
83            self;
84
85            super::sub::style_fn = style_fn!(|_| super::sub::SubMenuStyle!());
86
87            base_color = light_dark(rgb(0.82, 0.82, 0.82), rgb(0.18, 0.18, 0.18));
88            background_color = BASE_COLOR_VAR.rgba();
89            border = {
90                widths: 1,
91                sides: BASE_COLOR_VAR.shade_into(1),
92            };
93        }
94    }
95}
96
97/// Default sub-menu popup panel view.
98///
99/// See [`PANEL_FN_VAR`] for more details.
100pub fn default_panel_fn(args: zng_wgt_panel::PanelArgs) -> impl UiNode {
101    // remove arrow key shortcuts, they are used to nav. focus.
102    let scroll_id = WidgetId::new_unique();
103    let _ = zng_wgt_scroll::cmd::SCROLL_UP_CMD
104        .scoped(scroll_id)
105        .shortcut()
106        .set(Shortcuts::new());
107    let _ = zng_wgt_scroll::cmd::SCROLL_DOWN_CMD
108        .scoped(scroll_id)
109        .shortcut()
110        .set(Shortcuts::new());
111
112    zng_wgt_scroll::Scroll! {
113        id = scroll_id;
114        focusable = false;
115        child = Stack! {
116            children = args.children;
117            direction = zng_wgt_stack::StackDirection::top_to_bottom();
118        };
119        mode = zng_wgt_scroll::ScrollMode::VERTICAL;
120    }
121}
122
123/// Sub-menu popup implementation.
124pub fn sub_menu_popup_node(children: ArcNodeList<BoxedUiNodeList>, parent: Option<WidgetId>) -> impl UiNode {
125    let child = zng_wgt_panel::node(
126        children,
127        if parent.is_none() {
128            super::context::PANEL_FN_VAR
129        } else {
130            PANEL_FN_VAR
131        },
132    );
133    let mut close_timer = None;
134    match_node(child, move |c, op| match op {
135        UiNodeOp::Init => {
136            WIDGET
137                .sub_event(&KEY_INPUT_EVENT)
138                .sub_event(&POPUP_CLOSE_REQUESTED_EVENT)
139                .sub_event(&FOCUS_CHANGED_EVENT);
140        }
141        UiNodeOp::Deinit => {
142            close_timer = None;
143        }
144        UiNodeOp::Info { info } => {
145            // sub-menus set the popup as parent in context menu.
146            let parent_ctx = Some(parent.unwrap_or_else(|| WIDGET.id()));
147            super::sub::SUB_MENU_PARENT_CTX.with_context(&mut Some(Arc::new(parent_ctx)), || c.info(info));
148            info.set_meta(*super::sub::SUB_MENU_POPUP_ID, super::sub::SubMenuPopupInfo { parent });
149        }
150        UiNodeOp::Event { update } => {
151            c.event(update);
152
153            if let Some(args) = KEY_INPUT_EVENT.on_unhandled(update) {
154                if let KeyState::Pressed = args.state {
155                    match &args.key {
156                        Key::Escape => {
157                            let info = WIDGET.info();
158                            if let Some(m) = info.submenu_parent() {
159                                args.propagation().stop();
160
161                                FOCUS.focus_widget(m.id(), true);
162                                POPUP.force_close_id(info.id());
163                            }
164                        }
165                        Key::ArrowLeft | Key::ArrowRight => {
166                            if let Some(info) = WINDOW.info().get(args.target.widget_id()) {
167                                let info = info.into_focus_info(true, true);
168                                if info.focusable_left().is_none() && info.focusable_right().is_none() {
169                                    // escape to parent or change root.
170                                    if let Some(m) = info.info().submenu_parent() {
171                                        let mut escape = false;
172                                        if m.submenu_parent().is_some() {
173                                            if let Some(o) = m.orientation_from(info.info().center()) {
174                                                escape = match o {
175                                                    Orientation2D::Left => args.key == Key::ArrowLeft,
176                                                    Orientation2D::Right => args.key == Key::ArrowRight,
177                                                    Orientation2D::Below | Orientation2D::Above => false,
178                                                };
179                                            }
180                                        }
181
182                                        if escape {
183                                            args.propagation().stop();
184                                            // escape
185
186                                            FOCUS.focus_widget(m.id(), true);
187                                            POPUP.force_close_id(WIDGET.id());
188                                        } else if let Some(m) = info.info().submenu_root() {
189                                            args.propagation().stop();
190                                            // change root
191
192                                            let m = m.into_focus_info(true, true);
193                                            let next_root = match &args.key {
194                                                Key::ArrowLeft => m.next_left(),
195                                                Key::ArrowRight => m.next_right(),
196                                                _ => unreachable!(),
197                                            };
198                                            if let Some(n) = next_root {
199                                                FOCUS.focus_widget(n.info().id(), true);
200                                            }
201                                        }
202                                    }
203                                }
204                            }
205                        }
206                        _ => {}
207                    }
208                }
209            } else if let Some(args) = POPUP_CLOSE_REQUESTED_EVENT.on_unhandled(update) {
210                let sub_self = if parent.is_some() {
211                    WIDGET.info().submenu_parent()
212                } else {
213                    // is context menu
214                    Some(WIDGET.info())
215                };
216                if let Some(sub_self) = sub_self {
217                    let mut close_ancestors = Some(None);
218
219                    if let Some(focused) = FOCUS.focused().get() {
220                        if let Some(focused) = sub_self.tree().get(focused.widget_id()) {
221                            if let Some(sub_focused) = focused.submenu_parent() {
222                                if sub_focused.submenu_ancestors().any(|a| a.id() == sub_self.id()) {
223                                    // keep open, focused child.
224                                    args.propagation().stop();
225                                    close_ancestors = None;
226                                } else if sub_self.submenu_ancestors().any(|a| a.id() == sub_focused.id()) {
227                                    if Some(sub_focused.id()) == sub_self.submenu_parent().map(|s| s.id()) {
228                                        // keep open, focused parent.
229                                        args.propagation().stop();
230                                        close_ancestors = None;
231                                    } else {
232                                        close_ancestors = Some(Some(sub_focused.id()));
233                                    }
234                                }
235                            }
236                        }
237                    }
238
239                    if let Some(sub_parent_focused) = close_ancestors {
240                        // close any parent sub-menu that is not focused.
241                        for a in sub_self.submenu_ancestors() {
242                            if Some(a.id()) == sub_parent_focused {
243                                break;
244                            }
245
246                            if let Some(v) = a.is_submenu_open() {
247                                if v.get() {
248                                    // request ancestor close the popup.
249                                    POPUP_CLOSE_CMD.scoped(a.id()).notify();
250                                }
251                            } else if a.menu().is_none() {
252                                // request context menu popup close
253                                POPUP_CLOSE_CMD.scoped(a.id()).notify_param(PopupCloseMode::Force);
254                            }
255                        }
256                    }
257                }
258            } else if let Some(args) = FOCUS_CHANGED_EVENT.on(update) {
259                if args.is_focus_leave(WIDGET.id()) {
260                    if let Some(f) = &args.new_focus {
261                        let info = WIDGET.info();
262                        let sub_self = if parent.is_some() {
263                            info.submenu_parent()
264                        } else {
265                            // is context menu
266                            Some(info.clone())
267                        };
268                        if let (Some(sub_menu), Some(f)) = (sub_self, info.tree().get(f.widget_id())) {
269                            if !f.submenu_self_and_ancestors().any(|s| s.id() == sub_menu.id()) {
270                                // Focus did not move to child sub-menu nor parent,
271                                // close after delay.
272                                //
273                                // This covers the case of focus moving to a widget that is not
274                                // a child sub-menu and is not the parent sub-menu,
275                                // `sub_menu_node` covers the case of focus moving to the parent sub-menu and out.
276                                let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
277                                t.subscribe(UpdateOp::Update, info.id()).perm();
278                                close_timer = Some(t);
279                            }
280                        }
281                    }
282                }
283            }
284        }
285        UiNodeOp::Update { .. } => {
286            if let Some(t) = &close_timer {
287                if t.get().has_elapsed() {
288                    close_timer = None;
289                    POPUP.force_close_id(WIDGET.id());
290                }
291            }
292        }
293        _ => {}
294    })
295}