Skip to main content

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