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