1use std::time::Duration;
4
5use super::{icon_fn, shortcut_txt};
6use colors::BASE_COLOR_VAR;
7use zng_ext_font::FONTS;
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, base_color, is_disabled, is_mobile, is_rtl, prelude::*};
16use zng_wgt_access::{AccessRole, access_role};
17use zng_wgt_button::BUTTON;
18use zng_wgt_container::{child_align, padding};
19use zng_wgt_fill::{background, background_color, foreground_highlight};
20use zng_wgt_filter::{opacity, saturate};
21use zng_wgt_input::{
22 CursorIcon, click_mode, cursor,
23 focus::{FocusClickBehavior, focus_click_behavior, focusable, is_focused},
24 gesture::mnemonic,
25 is_hovered,
26 mouse::on_pre_mouse_enter,
27 pointer_capture::capture_pointer,
28};
29use zng_wgt_layer::{
30 AnchorMode, AnchorOffset, AnchorSize,
31 popup::{POPUP, POPUP_CLOSE_CMD, PopupState},
32};
33use zng_wgt_shortcut::ShortcutText;
34use zng_wgt_size_offset::{size, width};
35use zng_wgt_style::{Style, StyleMix, impl_style_fn, style_fn};
36
37#[widget($crate::sub::SubMenu {
39 ($header_txt:expr, $children:expr $(,)?) => {
40 header = $crate::MENU_TEXT_INPUT.label($header_txt);
41 children = $children;
42 }
43})]
44pub struct SubMenu(StyleMix<WidgetBase>);
45impl SubMenu {
46 widget_impl! {
47 pub crate::popup::children(children: impl IntoUiNode);
49 }
50
51 fn widget_intrinsic(&mut self) {
52 self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
53 widget_set! {
54 self;
55 focusable = true;
56 mnemonic = true;
57 click_mode = ClickMode::press();
58 focus_click_behavior = FocusClickBehavior::Ignore; capture_pointer = true; }
61
62 self.widget_builder().push_build_action(|wgt| {
63 let header = wgt
64 .capture_ui_node(property_id!(Self::header))
65 .unwrap_or_else(|| FillUiNode.into_node());
66
67 let children = wgt
68 .capture_property(property_id!(Self::children))
69 .map(|p| p.args.ui_node(0).clone())
70 .unwrap_or_else(|| ArcNode::new(ui_vec![]));
71
72 wgt.set_child(header);
73
74 wgt.push_intrinsic(NestGroup::EVENT, "sub_menu_node", |c| sub_menu_node(c, children));
75 });
76 }
77}
78impl_style_fn!(SubMenu, DefaultStyle);
79
80pub fn sub_menu_node(child: impl IntoUiNode, children: ArcNode) -> UiNode {
82 let mut open = None::<Var<PopupState>>;
83 let is_open = var(false);
84 let mut open_timer = None::<DeadlineVar>;
85 let mut close_timer = None::<DeadlineVar>;
86 let child = with_context_var(child, IS_OPEN_VAR, is_open.clone());
87 let mut close_cmd = CommandHandle::dummy();
88
89 match_node(child, move |_, op| {
90 let mut open_pop = false;
91
92 match op {
93 UiNodeOp::Init => {
94 WIDGET
95 .sub_event(&CLICK_EVENT)
96 .sub_event(&KEY_INPUT_EVENT)
97 .sub_event(&FOCUS_CHANGED_EVENT)
98 .sub_event(&MOUSE_HOVERED_EVENT);
99
100 close_cmd = POPUP_CLOSE_CMD.scoped(WIDGET.id()).subscribe(false);
101
102 let has_open = super::OPEN_SUBMENU_VAR.current_context();
103 if !has_open.capabilities().is_const() {
104 let handle = is_open.hook(move |v| {
105 if *v.value() {
106 has_open.modify(|v| **v += 1);
107 } else {
108 has_open.modify(|v| **v -= 1);
109 }
110 true
111 });
112 WIDGET.push_var_handle(handle);
113 }
114 }
115 UiNodeOp::Deinit => {
116 if let Some(v) = open.take() {
117 POPUP.force_close(&v);
118 is_open.set(false);
119 }
120 close_cmd = CommandHandle::dummy();
121 open_timer = None;
122 close_timer = None;
123 }
124 UiNodeOp::Info { info } => {
125 info.set_meta(
126 *SUB_MENU_INFO_ID,
127 SubMenuInfo {
128 parent: SUB_MENU_PARENT_CTX.get_clone(),
129 is_open: is_open.clone(),
130 },
131 );
132 }
133
134 UiNodeOp::Update { .. } => {
135 if let Some(s) = &open {
136 if matches!(s.get(), PopupState::Closed) {
137 is_open.set(false);
138 close_cmd.enabled().set(false);
139 close_timer = None;
140 open = None;
141 } else if let Some(t) = &close_timer
142 && t.get().has_elapsed()
143 {
144 if let Some(s) = open.take()
145 && !matches!(s.get(), PopupState::Closed)
146 {
147 POPUP.force_close(&s);
148 is_open.set(false);
149 close_cmd.enabled().set(false);
150 }
151 close_timer = None;
152 }
153 } else if let Some(t) = &open_timer
154 && t.get().has_elapsed()
155 {
156 open_pop = true;
157 }
158
159 MOUSE_HOVERED_EVENT.each_update(true, |args| {
160 let wgt = (WINDOW.id(), WIDGET.id());
161 if args.is_mouse_enter_enabled(wgt) {
162 let info = WIDGET.info();
163
164 let is_root = info.submenu_parent().is_none();
165 let is_open = is_open.get();
166
167 if is_root {
168 if !is_open
172 && let Some(menu) = info.menu()
173 && let Some(focused) = FOCUS.focused().get()
174 {
175 let is_menu_focused = focused.contains(menu.id());
176
177 let mut focus_on_hover = is_menu_focused;
178 if !focus_on_hover
179 && let Some(focused) = info.tree().get(focused.widget_id())
180 && let Some(f_menu) = focused.menu()
181 {
182 focus_on_hover = f_menu.id() == menu.id();
184 }
185
186 if focus_on_hover {
187 FOCUS.focus_widget(WIDGET.id(), false);
189 }
190 }
191 } else if !is_open && open_timer.is_none() {
192 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
194 t.subscribe(UpdateOp::Update, WIDGET.id()).perm();
195 open_timer = Some(t);
196 }
197 } else if args.is_mouse_leave_enabled(wgt) {
198 open_timer = None;
199 }
200 });
201 KEY_INPUT_EVENT.each_update(true, |args| {
202 if let KeyState::Pressed = args.state
203 && args.target.contains_enabled(WIDGET.id())
204 && !is_open.get()
205 {
206 if let Some(info) = WIDGET.info().into_focusable(true, true) {
207 if info.info().submenu_parent().is_none() {
208 if matches!(&args.key, Key::ArrowUp | Key::ArrowDown) {
210 open_pop = info.focusable_down().is_none() && info.focusable_up().is_none();
211 } else if matches!(&args.key, Key::ArrowLeft | Key::ArrowRight) {
212 open_pop = info.focusable_left().is_none() && info.focusable_right().is_none();
213 }
214 } else {
215 match DIRECTION_VAR.get() {
217 LayoutDirection::LTR => open_pop = matches!(&args.key, Key::ArrowRight),
218 LayoutDirection::RTL => open_pop = matches!(&args.key, Key::ArrowLeft),
219 }
220 }
221 }
222
223 if open_pop {
224 args.propagation.stop();
225 }
226 }
227 });
228 FOCUS_CHANGED_EVENT.each_update(true, |args| {
229 if args.is_focus_enter_enabled(WIDGET.id()) {
230 close_timer = None;
231 if !is_open.get() {
232 let info = WIDGET.info();
235 if info.submenu_parent().is_none() && let Some(prev_root) = args
237 .prev_focus
238 .as_ref()
239 .and_then(|p| info.tree().get(p.widget_id()))
240 .and_then(|w| w.submenu_root()) && prev_root.is_submenu_open().map(|v| v.get()).unwrap_or(false)
242 && let Some(prev_menu) = prev_root.menu()
243 && let Some(our_menu) = info.menu()
244 {
245 open_pop = our_menu.id() == prev_menu.id();
247 }
248 }
249 } else if args.is_focus_leave_enabled(WIDGET.id())
250 && is_open.get()
251 && let Some(f) = &args.new_focus
252 && let Some(f) = WINDOW.info().get(f.widget_id())
253 {
254 let id = WIDGET.id();
255 if !f.submenu_ancestors().any(|s| s.id() == id) {
256 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
262 t.subscribe(UpdateOp::Update, id).perm();
263 close_timer = Some(t);
264 }
265 }
266 });
267
268 CLICK_EVENT.each_update(true, |args| {
269 if args.is_primary() && args.target.contains_enabled(WIDGET.id()) {
270 args.propagation.stop();
271
272 open_pop = if let Some(s) = open.take() {
274 let closed = matches!(s.get(), PopupState::Closed);
275 if !closed {
276 if WIDGET.info().submenu_parent().is_none() {
277 POPUP.force_close(&s);
279 FOCUS.focus_exit(true);
280 is_open.set(false);
281 close_cmd.enabled().set(false);
282 } else {
283 open = Some(s);
285 }
286 }
287 closed
288 } else {
289 true
290 };
291 if !open_pop && open.is_none() {
292 is_open.set(false);
293 }
294 }
295 });
296
297 if POPUP_CLOSE_CMD.scoped(WIDGET.id()).has_update(true, true)
298 && let Some(s) = open.take()
299 && !matches!(s.get(), PopupState::Closed)
300 {
301 POPUP.force_close(&s);
302 is_open.set(false);
303 close_cmd.enabled().set(false);
304 }
305 }
306 _ => {}
307 }
308 if open_pop {
309 let pop = super::popup::SubMenuPopup! {
310 parent_id = WIDGET.id();
311 children = children.take_on_init();
312 };
313 let state = POPUP.open(pop);
314 state.subscribe(UpdateOp::Update, WIDGET.id()).perm();
315 if !matches!(state.get(), PopupState::Closed) {
316 is_open.set(true);
317 close_cmd.enabled().set(true);
318 }
319 open = Some(state);
320 open_timer = None;
321 }
322 })
323}
324
325#[property(CHILD, default(FillUiNode), widget_impl(SubMenu))]
327pub fn header(wgt: &mut WidgetBuilding, child: impl IntoUiNode) {
328 let _ = child;
329 wgt.expect_property_capture();
330}
331
332#[property(CONTEXT, default(START_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
336pub fn start_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
337 with_context_var(child, START_COLUMN_WIDTH_VAR, width)
338}
339
340#[property(CONTEXT, default(END_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
344pub fn end_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
345 with_context_var(child, END_COLUMN_WIDTH_VAR, width)
346}
347
348#[property(FILL)]
359pub fn start_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
360 let cell = width(cell, START_COLUMN_WIDTH_VAR);
361 let cell = align(cell, Align::FILL_START);
362 background(child, cell)
363}
364
365#[property(FILL)]
376pub fn end_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
377 let cell = width(cell, END_COLUMN_WIDTH_VAR);
378 let cell = align(cell, Align::FILL_END);
379 background(child, cell)
380}
381
382#[property(FILL)]
391pub fn start_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
392 start_column(child, presenter((), cell_fn))
393}
394
395#[property(FILL)]
404pub fn end_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
405 end_column(child, presenter((), cell_fn))
406}
407
408#[property(CHILD_LAYOUT, default(false))]
415pub fn column_width_padding(child: impl IntoUiNode, enabled: impl IntoVar<bool>) -> UiNode {
416 let spacing = merge_var!(
417 START_COLUMN_WIDTH_VAR,
418 END_COLUMN_WIDTH_VAR,
419 DIRECTION_VAR,
420 enabled.into_var(),
421 |s, e, d, enabled| {
422 if *enabled {
423 let s = s.clone();
424 let e = e.clone();
425 if d.is_ltr() {
426 SideOffsets::new(0, e, 0, s)
427 } else {
428 SideOffsets::new(0, s, 0, e)
429 }
430 } else {
431 SideOffsets::zero()
432 }
433 }
434 );
435 padding(child, spacing)
436}
437
438context_var! {
439 pub static START_COLUMN_WIDTH_VAR: Length = 32;
441
442 pub static END_COLUMN_WIDTH_VAR: Length = 24;
444
445 pub static HOVER_OPEN_DELAY_VAR: Duration = 150.ms();
449
450 static IS_OPEN_VAR: bool = false;
451}
452
453#[property(EVENT, widget_impl(SubMenu, DefaultStyle))]
455pub fn is_open(child: impl IntoUiNode, state: impl IntoVar<bool>) -> UiNode {
456 bind_state(child, IS_OPEN_VAR, state)
457}
458
459#[property(CONTEXT, default(HOVER_OPEN_DELAY_VAR), widget_impl(SubMenu, DefaultStyle))]
465pub fn hover_open_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
466 with_context_var(child, HOVER_OPEN_DELAY_VAR, delay)
467}
468
469#[widget($crate::sub::DefaultStyle)]
474pub struct DefaultStyle(Style);
475impl DefaultStyle {
476 fn widget_intrinsic(&mut self) {
477 widget_set! {
478 self;
479
480 replace = true;
481
482 padding = (4, 10);
483 opacity = 90.pct();
484 foreground_highlight = unset!;
485
486 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| match d {
487 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
488 place: Point::bottom_left(),
489 origin: Point::top_left(),
490 }),
491 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
492 place: Point::bottom_right(),
493 origin: Point::top_right(),
494 }),
495 });
496
497 zng_wgt_button::style_fn = style_fn!(|_| ButtonStyle!());
498 zng_wgt_toggle::style_fn = style_fn!(|_| ToggleStyle!());
499 zng_wgt_rule_line::hr::color = BASE_COLOR_VAR.shade(1);
500 zng_wgt_rule_line::vr::color = BASE_COLOR_VAR.shade(1);
501 zng_wgt_rule_line::vr::height = 1.em();
502 zng_wgt_text::icon::ico_size = 18;
503
504 when *#is_hovered || *#is_focused || *#is_open {
505 background_color = BASE_COLOR_VAR.shade(1);
506 opacity = 100.pct();
507 }
508
509 when *#is_disabled {
510 saturate = false;
511 opacity = 50.pct();
512 cursor = CursorIcon::NotAllowed;
513 }
514 }
515 }
516}
517
518#[widget($crate::sub::SubMenuStyle)]
522pub struct SubMenuStyle(ButtonStyle);
523impl SubMenuStyle {
524 fn widget_intrinsic(&mut self) {
525 widget_set! {
526 self;
527
528 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| {
529 match d {
530 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
531 place: Point::top_right(),
532 origin: Point::top_left(),
533 }),
534 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
535 place: Point::top_left(),
536 origin: Point::top_right(),
537 }),
538 }
539 .with_min_size(AnchorSize::Unbounded)
540 });
541
542 when *#is_open {
543 background_color = BASE_COLOR_VAR.shade(1);
544 opacity = 100.pct();
545 }
546
547 end_column_fn = wgt_fn!(|_| zng_wgt_text::Text! {
548 size = 1.2.em();
549 font_family = FONTS.generics().system_ui(&lang!(und));
550 align = Align::CENTER;
551
552 txt = "⏵";
553 when *#is_rtl {
554 txt = "⏴";
555 }
556 });
557 }
558 }
559}
560
561pub trait SubMenuWidgetInfoExt {
565 fn is_submenu(&self) -> bool;
569
570 fn is_submenu_open(&self) -> Option<Var<bool>>;
572
573 fn submenu_parent(&self) -> Option<WidgetInfo>;
581
582 fn submenu_ancestors(&self) -> SubMenuAncestors;
584 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors;
586
587 fn submenu_root(&self) -> Option<WidgetInfo>;
589
590 fn menu(&self) -> Option<WidgetInfo>;
594}
595impl SubMenuWidgetInfoExt for WidgetInfo {
596 fn is_submenu(&self) -> bool {
597 self.meta().contains(*SUB_MENU_INFO_ID)
598 }
599
600 fn is_submenu_open(&self) -> Option<Var<bool>> {
601 self.meta().get(*SUB_MENU_INFO_ID).map(|s| s.is_open.read_only())
602 }
603
604 fn submenu_parent(&self) -> Option<WidgetInfo> {
605 if let Some(p) = self.meta().get(*SUB_MENU_INFO_ID) {
606 self.tree().get(p.parent?)
607 } else if let Some(p) = self.ancestors().find(|a| a.is_submenu()) {
608 Some(p)
609 } else if let Some(pop) = self.meta().get(*SUB_MENU_POPUP_ID) {
610 self.tree().get(pop.parent?)
611 } else {
612 for anc in self.ancestors() {
613 if let Some(pop) = anc.meta().get(*SUB_MENU_POPUP_ID) {
614 if let Some(p) = pop.parent {
615 return self.tree().get(p);
616 } else {
617 return Some(anc);
619 }
620 }
621 }
622 None
623 }
624 }
625
626 fn submenu_ancestors(&self) -> SubMenuAncestors {
627 SubMenuAncestors {
628 node: self.submenu_parent(),
629 }
630 }
631
632 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors {
633 if self.is_submenu() {
634 SubMenuAncestors { node: Some(self.clone()) }
635 } else {
636 self.submenu_ancestors()
637 }
638 }
639
640 fn submenu_root(&self) -> Option<WidgetInfo> {
641 self.submenu_ancestors().last()
642 }
643
644 fn menu(&self) -> Option<WidgetInfo> {
645 let root = self
646 .submenu_root()
647 .or_else(|| if self.is_submenu() { Some(self.clone()) } else { None })?;
648
649 let scope = root.into_focus_info(true, true).scope()?;
650
651 if !scope.is_alt_scope() {
652 return None;
653 }
654
655 Some(scope.info().clone())
656 }
657}
658
659pub struct SubMenuAncestors {
665 node: Option<WidgetInfo>,
666}
667impl Iterator for SubMenuAncestors {
668 type Item = WidgetInfo;
669
670 fn next(&mut self) -> Option<Self::Item> {
671 if let Some(n) = self.node.take() {
672 self.node = n.submenu_parent();
673 Some(n)
674 } else {
675 None
676 }
677 }
678}
679
680pub(super) struct SubMenuInfo {
681 pub parent: Option<WidgetId>,
682 pub is_open: Var<bool>,
683}
684
685pub(super) struct SubMenuPopupInfo {
686 pub parent: Option<WidgetId>,
687}
688
689context_local! {
690 pub(super) static SUB_MENU_PARENT_CTX: Option<WidgetId> = None;
692}
693
694static_id! {
695 pub(super) static ref SUB_MENU_INFO_ID: StateId<SubMenuInfo>;
696 pub(super) static ref SUB_MENU_POPUP_ID: StateId<SubMenuPopupInfo>;
697}
698
699#[widget($crate::sub::ButtonStyle)]
707pub struct ButtonStyle(Style);
708impl ButtonStyle {
709 fn widget_intrinsic(&mut self) {
710 widget_set! {
711 self;
712 replace = true;
713
714 column_width_padding = true;
715 padding = (4, 0);
716 child_align = Align::START;
717
718 base_color = light_dark(rgb(0.82, 0.82, 0.82), rgb(0.18, 0.18, 0.18));
719 background_color = BASE_COLOR_VAR.rgba();
720 opacity = 90.pct();
721 foreground_highlight = unset!;
722 zng_wgt_tooltip::tooltip_fn = WidgetFn::nil(); click_mode = ClickMode::release();access_role = AccessRole::MenuItem;
727
728 on_pre_mouse_enter = hn!(|_| {
729 FOCUS.focus_widget(WIDGET.id(), false);
730 });
731
732 shortcut_txt = ShortcutText! {
733 shortcut = BUTTON.cmd().flat_map(|c| match c {
734 Some(c) => c.shortcut(),
735 None => const_var(Shortcuts::default()),
736 });
737 align = Align::CENTER;
738 };
739
740 icon_fn = BUTTON.cmd().flat_map(|c| match c {
741 Some(c) => c.icon(),
742 None => const_var(WidgetFn::nil()),
743 });
744
745 zng_wgt_input::gesture::mnemonic = true;
746 zng_wgt_button::cmd_child_fn = wgt_fn!(|cmd: Command| crate::MENU_TEXT_INPUT.label(cmd.name()));
747
748 when *#is_focused {
749 background_color = BASE_COLOR_VAR.shade(1);
750 opacity = 100.pct();
751 }
752
753 when *#is_disabled {
754 saturate = false;
755 opacity = 50.pct();
756 cursor = CursorIcon::NotAllowed;
757 }
758
759 when *#is_mobile {
760 shortcut_txt = UiNode::nil();
761 }
762 }
763 }
764}
765
766#[widget($crate::sub::TouchButtonStyle)]
774pub struct TouchButtonStyle(Style);
775impl TouchButtonStyle {
776 fn widget_intrinsic(&mut self) {
777 widget_set! {
778 self;
779 zng_wgt::corner_radius = 0;
780 zng_wgt::visibility =
781 BUTTON
782 .cmd()
783 .flat_map(|c| match c {
784 Some(c) => c.is_enabled(),
785 None => const_var(true),
786 })
787 .map_into(),
788 ;
789 }
790 }
791}
792
793#[widget($crate::sub::ToggleStyle)]
801pub struct ToggleStyle(ButtonStyle);
802impl ToggleStyle {
803 fn widget_intrinsic(&mut self) {
804 widget_set! {
805 self;
806 replace = true;
807
808 click_mode = ClickMode::release();
809 access_role = AccessRole::MenuItemCheckBox;
810
811 start_column_fn = wgt_fn!(|_| zng_wgt_text::Text! {
812 size = 1.2.em();
813 font_family = FONTS.generics().system_ui(&lang!(und));
814 align = Align::CENTER;
815
816 txt = "✓";
817 when #{zng_wgt_toggle::IS_CHECKED_VAR}.is_none() {
818 txt = "━";
819 }
820
821 font_color = zng_wgt_text::FONT_COLOR_VAR.map(|c| c.transparent());
822 when #{zng_wgt_toggle::IS_CHECKED_VAR}.unwrap_or(true) {
823 font_color = zng_wgt_text::FONT_COLOR_VAR;
824 }
825 });
826 }
827 }
828}