1use std::time::Duration;
4
5use super::ButtonStyle;
6use colors::BASE_COLOR_VAR;
7use zng_ext_font::FontNames;
8use zng_ext_input::{
9 focus::{FOCUS, FOCUS_CHANGED_EVENT, WidgetInfoFocusExt as _},
10 gesture::CLICK_EVENT,
11 keyboard::{KEY_INPUT_EVENT, Key, KeyState},
12 mouse::{ClickMode, MOUSE_HOVERED_EVENT},
13};
14use zng_ext_l10n::lang;
15use zng_wgt::{align, is_disabled, is_rtl, prelude::*};
16use zng_wgt_container::padding;
17use zng_wgt_fill::{background, background_color, foreground_highlight};
18use zng_wgt_filter::{opacity, saturate};
19use zng_wgt_input::{
20 CursorIcon, click_mode, cursor,
21 focus::{FocusClickBehavior, focus_click_behavior, focusable, is_focused},
22 is_hovered,
23 pointer_capture::capture_pointer,
24};
25use zng_wgt_layer::{
26 AnchorMode, AnchorOffset, AnchorSize,
27 popup::{POPUP, POPUP_CLOSE_CMD, PopupState},
28};
29use zng_wgt_size_offset::{size, width};
30use zng_wgt_style::{Style, StyleMix, impl_style_fn, style_fn};
31#[doc(hidden)]
32pub use zng_wgt_text::Text;
33
34#[widget($crate::sub::SubMenu {
36 ($header_txt:expr, $children:expr $(,)?) => {
37 header = $crate::sub::Text!($header_txt);
38 children = $children;
39 }
40})]
41pub struct SubMenu(StyleMix<WidgetBase>);
42impl SubMenu {
43 widget_impl! {
44 pub crate::popup::children(children: impl UiNodeList);
46 }
47
48 fn widget_intrinsic(&mut self) {
49 self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
50 widget_set! {
51 self;
52 style_base_fn = style_fn!(|_| DefaultStyle!());
53 focusable = true;
54 click_mode = ClickMode::press();
55 focus_click_behavior = FocusClickBehavior::Ignore; capture_pointer = true; }
58
59 self.widget_builder().push_build_action(|wgt| {
60 let header = wgt
61 .capture_ui_node(property_id!(Self::header))
62 .unwrap_or_else(|| FillUiNode.boxed());
63
64 let children = wgt
65 .capture_property(property_id!(Self::children))
66 .map(|p| p.args.ui_node_list(0).clone())
67 .unwrap_or_else(|| ArcNodeList::new(ui_vec![].boxed()));
68
69 wgt.set_child(header);
70
71 wgt.push_intrinsic(NestGroup::EVENT, "sub_menu_node", |c| sub_menu_node(c, children));
72 });
73 }
74}
75impl_style_fn!(SubMenu);
76
77pub fn sub_menu_node(child: impl UiNode, children: ArcNodeList<BoxedUiNodeList>) -> impl UiNode {
79 let mut open = None::<ReadOnlyArcVar<PopupState>>;
80 let is_open = var(false);
81 let mut open_timer = None;
82 let mut close_timer = None;
83 let child = with_context_var(child, IS_OPEN_VAR, is_open.clone());
84 let mut close_cmd = CommandHandle::dummy();
85
86 match_node(child, move |_, op| {
87 let mut open_pop = false;
88
89 match op {
90 UiNodeOp::Init => {
91 WIDGET
92 .sub_event(&CLICK_EVENT)
93 .sub_event(&KEY_INPUT_EVENT)
94 .sub_event(&FOCUS_CHANGED_EVENT)
95 .sub_event(&MOUSE_HOVERED_EVENT);
96
97 close_cmd = POPUP_CLOSE_CMD.scoped(WIDGET.id()).subscribe(false);
98 }
99 UiNodeOp::Deinit => {
100 if let Some(v) = open.take() {
101 POPUP.force_close(&v);
102 is_open.set(false);
103 }
104 close_cmd = CommandHandle::dummy();
105 open_timer = None;
106 close_timer = None;
107 }
108 UiNodeOp::Info { info } => {
109 info.set_meta(
110 *SUB_MENU_INFO_ID,
111 SubMenuInfo {
112 parent: SUB_MENU_PARENT_CTX.get_clone(),
113 is_open: is_open.clone(),
114 },
115 );
116 }
117 UiNodeOp::Event { update } => {
118 if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
119 if args.is_mouse_enter() {
120 let info = WIDGET.info();
121
122 let is_root = info.submenu_parent().is_none();
123 let is_open = is_open.get();
124
125 if is_root {
126 if !is_open {
130 if let (Some(menu), Some(focused)) = (info.menu(), FOCUS.focused().get()) {
131 let is_menu_focused = focused.contains(menu.id());
132
133 let mut focus_on_hover = is_menu_focused;
134 if !focus_on_hover {
135 if let Some(focused) = info.tree().get(focused.widget_id()) {
136 if let Some(f_menu) = focused.menu() {
137 focus_on_hover = f_menu.id() == menu.id();
139 }
140 }
141 }
142
143 if focus_on_hover {
144 FOCUS.focus_widget(WIDGET.id(), false);
146 }
147 }
148 }
149 } else if !is_open && open_timer.is_none() {
150 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
152 t.subscribe(UpdateOp::Update, WIDGET.id()).perm();
153 open_timer = Some(t);
154 }
155 } else if args.is_mouse_leave() {
156 open_timer = None;
157 }
158 } else if let Some(args) = KEY_INPUT_EVENT.on_unhandled(update) {
159 if let KeyState::Pressed = args.state {
160 if !is_open.get() {
161 if let Some(info) = WIDGET.info().into_focusable(true, true) {
162 if info.info().submenu_parent().is_none() {
163 if matches!(&args.key, Key::ArrowUp | Key::ArrowDown) {
165 open_pop = info.focusable_down().is_none() && info.focusable_up().is_none();
166 } else if matches!(&args.key, Key::ArrowLeft | Key::ArrowRight) {
167 open_pop = info.focusable_left().is_none() && info.focusable_right().is_none();
168 }
169 } else {
170 match DIRECTION_VAR.get() {
172 LayoutDirection::LTR => open_pop = matches!(&args.key, Key::ArrowRight),
173 LayoutDirection::RTL => open_pop = matches!(&args.key, Key::ArrowLeft),
174 }
175 }
176 }
177
178 if open_pop {
179 args.propagation().stop();
180 }
181 }
182 }
183 } else if let Some(args) = FOCUS_CHANGED_EVENT.on(update) {
184 if args.is_focus_enter(WIDGET.id()) {
185 close_timer = None;
186 if !is_open.get() {
187 let info = WIDGET.info();
189 if info.submenu_parent().is_none() {
190 if let Some(prev_root) = args
192 .prev_focus
193 .as_ref()
194 .and_then(|p| info.tree().get(p.widget_id()))
195 .and_then(|w| w.submenu_root())
196 {
197 if prev_root.is_submenu_open().map(|v| v.get()).unwrap_or(false) {
199 if let (Some(prev_menu), Some(our_menu)) = (prev_root.menu(), info.menu()) {
200 open_pop = our_menu.id() == prev_menu.id();
202 }
203 }
204 }
205 }
206 }
207 } else if args.is_focus_leave(WIDGET.id()) && is_open.get() {
208 if let Some(f) = &args.new_focus {
209 if let Some(f) = WINDOW.info().get(f.widget_id()) {
210 let id = WIDGET.id();
211 if !f.submenu_ancestors().any(|s| s.id() == id) {
212 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
218 t.subscribe(UpdateOp::Update, id).perm();
219 close_timer = Some(t);
220 }
221 }
222 }
223 }
224 } else if let Some(args) = CLICK_EVENT.on(update) {
225 if args.is_primary() {
226 args.propagation().stop();
227
228 open_pop = if let Some(s) = open.take() {
230 let closed = matches!(s.get(), PopupState::Closed);
231 if !closed {
232 if WIDGET.info().submenu_parent().is_none() {
233 POPUP.force_close(&s);
235 FOCUS.focus_exit();
236 is_open.set(false);
237 close_cmd.set_enabled(false);
238 } else {
239 open = Some(s);
241 }
242 }
243 closed
244 } else {
245 true
246 };
247 if !open_pop && open.is_none() {
248 is_open.set(false);
249 }
250 }
251 } else if let Some(_args) = POPUP_CLOSE_CMD.scoped(WIDGET.id()).on(update) {
252 if let Some(s) = open.take() {
253 if !matches!(s.get(), PopupState::Closed) {
254 POPUP.force_close(&s);
255 is_open.set(false);
256 close_cmd.set_enabled(false);
257 }
258 }
259 }
260 }
261 UiNodeOp::Update { .. } => {
262 if let Some(s) = &open {
263 if matches!(s.get(), PopupState::Closed) {
264 is_open.set(false);
265 close_cmd.set_enabled(false);
266 close_timer = None;
267 open = None;
268 } else if let Some(t) = &close_timer {
269 if t.get().has_elapsed() {
270 if let Some(s) = open.take() {
271 if !matches!(s.get(), PopupState::Closed) {
272 POPUP.force_close(&s);
273 is_open.set(false);
274 close_cmd.set_enabled(false);
275 }
276 }
277 close_timer = None;
278 }
279 }
280 } else if let Some(t) = &open_timer {
281 if t.get().has_elapsed() {
282 open_pop = true;
283 }
284 }
285 }
286 _ => {}
287 }
288 if open_pop {
289 let pop = super::popup::SubMenuPopup! {
290 parent_id = WIDGET.id();
291 children = children.take_on_init().boxed();
292 };
293 let state = POPUP.open(pop);
294 state.subscribe(UpdateOp::Update, WIDGET.id()).perm();
295 if !matches!(state.get(), PopupState::Closed) {
296 is_open.set(true);
297 close_cmd.set_enabled(true);
298 }
299 open = Some(state);
300 open_timer = None;
301 }
302 })
303}
304
305#[property(CHILD, capture, default(FillUiNode), widget_impl(SubMenu))]
307pub fn header(child: impl UiNode) {}
308
309#[property(CONTEXT, default(START_COLUMN_WIDTH_VAR), widget_impl(SubMenu))]
313pub fn start_column_width(child: impl UiNode, width: impl IntoVar<Length>) -> impl UiNode {
314 with_context_var(child, START_COLUMN_WIDTH_VAR, width)
315}
316
317#[property(CONTEXT, default(END_COLUMN_WIDTH_VAR), widget_impl(SubMenu))]
321pub fn end_column_width(child: impl UiNode, width: impl IntoVar<Length>) -> impl UiNode {
322 with_context_var(child, END_COLUMN_WIDTH_VAR, width)
323}
324
325#[property(FILL)]
336pub fn start_column(child: impl UiNode, cell: impl UiNode) -> impl UiNode {
337 let cell = width(cell, START_COLUMN_WIDTH_VAR);
338 let cell = align(cell, Align::FILL_START);
339 background(child, cell)
340}
341
342#[property(FILL)]
353pub fn end_column(child: impl UiNode, cell: impl UiNode) -> impl UiNode {
354 let cell = width(cell, END_COLUMN_WIDTH_VAR);
355 let cell = align(cell, Align::FILL_END);
356 background(child, cell)
357}
358
359#[property(FILL)]
368pub fn start_column_fn(child: impl UiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> impl UiNode {
369 start_column(child, presenter((), cell_fn))
370}
371
372#[property(FILL)]
381pub fn end_column_fn(child: impl UiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> impl UiNode {
382 end_column(child, presenter((), cell_fn))
383}
384
385#[property(CHILD_LAYOUT, default(false))]
392pub fn column_width_padding(child: impl UiNode, enabled: impl IntoVar<bool>) -> impl UiNode {
393 let spacing = merge_var!(
394 START_COLUMN_WIDTH_VAR,
395 END_COLUMN_WIDTH_VAR,
396 DIRECTION_VAR,
397 enabled.into_var(),
398 |s, e, d, enabled| {
399 if *enabled {
400 let s = s.clone();
401 let e = e.clone();
402 if d.is_ltr() {
403 SideOffsets::new(0, e, 0, s)
404 } else {
405 SideOffsets::new(0, s, 0, e)
406 }
407 } else {
408 SideOffsets::zero()
409 }
410 }
411 );
412 padding(child, spacing)
413}
414
415context_var! {
416 pub static START_COLUMN_WIDTH_VAR: Length = 32;
418
419 pub static END_COLUMN_WIDTH_VAR: Length = 24;
421
422 pub static HOVER_OPEN_DELAY_VAR: Duration = 300.ms();
426
427 static IS_OPEN_VAR: bool = false;
428}
429
430#[property(EVENT, widget_impl(SubMenu))]
432pub fn is_open(child: impl UiNode, state: impl IntoVar<bool>) -> impl UiNode {
433 bind_state(child, IS_OPEN_VAR, state)
434}
435
436#[property(CONTEXT, default(HOVER_OPEN_DELAY_VAR), widget_impl(SubMenu))]
442pub fn hover_open_delay(child: impl UiNode, delay: impl IntoVar<Duration>) -> impl UiNode {
443 with_context_var(child, HOVER_OPEN_DELAY_VAR, delay)
444}
445
446#[widget($crate::sub::DefaultStyle)]
451pub struct DefaultStyle(Style);
452impl DefaultStyle {
453 fn widget_intrinsic(&mut self) {
454 widget_set! {
455 self;
456
457 replace = true;
458
459 padding = (4, 10);
460 opacity = 90.pct();
461 foreground_highlight = unset!;
462
463 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| match d {
464 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset { place: Point::bottom_left(), origin: Point::top_left() }),
465 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset { place: Point::bottom_right(), origin: Point::top_right() }),
466 });
467
468 when *#is_hovered || *#is_focused || *#is_open {
469 background_color = BASE_COLOR_VAR.shade(1);
470 opacity = 100.pct();
471 }
472
473 when *#is_disabled {
474 saturate = false;
475 opacity = 50.pct();
476 cursor = CursorIcon::NotAllowed;
477 }
478 }
479 }
480}
481
482#[widget($crate::sub::SubMenuStyle)]
486pub struct SubMenuStyle(ButtonStyle);
487impl SubMenuStyle {
488 fn widget_intrinsic(&mut self) {
489 widget_set! {
490 self;
491
492 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| {
493 match d {
494 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
495 place: Point::top_right(),
496 origin: Point::top_left(),
497 }),
498 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
499 place: Point::top_left(),
500 origin: Point::top_right(),
501 }),
502 }
503 .with_min_size(AnchorSize::Unbounded)
504 });
505
506 when *#is_open {
507 background_color = BASE_COLOR_VAR.shade(1);
508 opacity = 100.pct();
509 }
510
511 end_column_fn = wgt_fn!(|_| zng_wgt_text::Text! {
512 size = 1.2.em();
513 font_family = FontNames::system_ui(&lang!(und));
514 align = Align::CENTER;
515
516 txt = "⏵";
517 when *#is_rtl {
518 txt = "⏴";
519 }
520 })
521 }
522 }
523}
524
525pub trait SubMenuWidgetInfoExt {
529 fn is_submenu(&self) -> bool;
533
534 fn is_submenu_open(&self) -> Option<ReadOnlyArcVar<bool>>;
536
537 fn submenu_parent(&self) -> Option<WidgetInfo>;
545
546 fn submenu_ancestors(&self) -> SubMenuAncestors;
548 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors;
550
551 fn submenu_root(&self) -> Option<WidgetInfo>;
553
554 fn menu(&self) -> Option<WidgetInfo>;
558}
559impl SubMenuWidgetInfoExt for WidgetInfo {
560 fn is_submenu(&self) -> bool {
561 self.meta().contains(*SUB_MENU_INFO_ID)
562 }
563
564 fn is_submenu_open(&self) -> Option<ReadOnlyArcVar<bool>> {
565 self.meta().get(*SUB_MENU_INFO_ID).map(|s| s.is_open.read_only())
566 }
567
568 fn submenu_parent(&self) -> Option<WidgetInfo> {
569 if let Some(p) = self.meta().get(*SUB_MENU_INFO_ID) {
570 self.tree().get(p.parent?)
571 } else if let Some(p) = self.ancestors().find(|a| a.is_submenu()) {
572 Some(p)
573 } else if let Some(pop) = self.meta().get(*SUB_MENU_POPUP_ID) {
574 self.tree().get(pop.parent?)
575 } else {
576 for anc in self.ancestors() {
577 if let Some(pop) = anc.meta().get(*SUB_MENU_POPUP_ID) {
578 if let Some(p) = pop.parent {
579 return self.tree().get(p);
580 } else {
581 return Some(anc);
583 }
584 }
585 }
586 None
587 }
588 }
589
590 fn submenu_ancestors(&self) -> SubMenuAncestors {
591 SubMenuAncestors {
592 node: self.submenu_parent(),
593 }
594 }
595
596 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors {
597 if self.is_submenu() {
598 SubMenuAncestors { node: Some(self.clone()) }
599 } else {
600 self.submenu_ancestors()
601 }
602 }
603
604 fn submenu_root(&self) -> Option<WidgetInfo> {
605 self.submenu_ancestors().last()
606 }
607
608 fn menu(&self) -> Option<WidgetInfo> {
609 let root = self
610 .submenu_root()
611 .or_else(|| if self.is_submenu() { Some(self.clone()) } else { None })?;
612
613 let scope = root.into_focus_info(true, true).scope()?;
614
615 if !scope.is_alt_scope() {
616 return None;
617 }
618
619 Some(scope.info().clone())
620 }
621}
622
623pub struct SubMenuAncestors {
629 node: Option<WidgetInfo>,
630}
631impl Iterator for SubMenuAncestors {
632 type Item = WidgetInfo;
633
634 fn next(&mut self) -> Option<Self::Item> {
635 if let Some(n) = self.node.take() {
636 self.node = n.submenu_parent();
637 Some(n)
638 } else {
639 None
640 }
641 }
642}
643
644pub(super) struct SubMenuInfo {
645 pub parent: Option<WidgetId>,
646 pub is_open: ArcVar<bool>,
647}
648
649pub(super) struct SubMenuPopupInfo {
650 pub parent: Option<WidgetId>,
651}
652
653context_local! {
654 pub(super) static SUB_MENU_PARENT_CTX: Option<WidgetId> = None;
656}
657
658static_id! {
659 pub(super) static ref SUB_MENU_INFO_ID: StateId<SubMenuInfo>;
660 pub(super) static ref SUB_MENU_POPUP_ID: StateId<SubMenuPopupInfo>;
661}