1use std::time::Duration;
4
5use super::{icon_fn, shortcut_txt};
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, 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 is_hovered,
25 mouse::on_pre_mouse_enter,
26 pointer_capture::capture_pointer,
27};
28use zng_wgt_layer::{
29 AnchorMode, AnchorOffset, AnchorSize,
30 popup::{POPUP, POPUP_CLOSE_CMD, PopupState},
31};
32use zng_wgt_shortcut::ShortcutText;
33use zng_wgt_size_offset::{size, width};
34use zng_wgt_style::{Style, StyleMix, impl_style_fn, style_fn};
35
36#[doc(hidden)]
37pub use zng_wgt_text::Text;
38
39#[widget($crate::sub::SubMenu {
41 ($header_txt:expr, $children:expr $(,)?) => {
42 header = $crate::sub::Text!($header_txt);
43 children = $children;
44 }
45})]
46pub struct SubMenu(StyleMix<WidgetBase>);
47impl SubMenu {
48 widget_impl! {
49 pub crate::popup::children(children: impl IntoUiNode);
51 }
52
53 fn widget_intrinsic(&mut self) {
54 self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
55 widget_set! {
56 self;
57 focusable = true;
58 click_mode = ClickMode::press();
59 focus_click_behavior = FocusClickBehavior::Ignore; capture_pointer = true; }
62
63 self.widget_builder().push_build_action(|wgt| {
64 let header = wgt
65 .capture_ui_node(property_id!(Self::header))
66 .unwrap_or_else(|| FillUiNode.into_node());
67
68 let children = wgt
69 .capture_property(property_id!(Self::children))
70 .map(|p| p.args.ui_node(0).clone())
71 .unwrap_or_else(|| ArcNode::new(ui_vec![]));
72
73 wgt.set_child(header);
74
75 wgt.push_intrinsic(NestGroup::EVENT, "sub_menu_node", |c| sub_menu_node(c, children));
76 });
77 }
78}
79impl_style_fn!(SubMenu, DefaultStyle);
80
81pub fn sub_menu_node(child: impl IntoUiNode, children: ArcNode) -> UiNode {
83 let mut open = None::<Var<PopupState>>;
84 let is_open = var(false);
85 let mut open_timer = None;
86 let mut close_timer = None;
87 let child = with_context_var(child, IS_OPEN_VAR, is_open.clone());
88 let mut close_cmd = CommandHandle::dummy();
89
90 match_node(child, move |_, op| {
91 let mut open_pop = false;
92
93 match op {
94 UiNodeOp::Init => {
95 WIDGET
96 .sub_event(&CLICK_EVENT)
97 .sub_event(&KEY_INPUT_EVENT)
98 .sub_event(&FOCUS_CHANGED_EVENT)
99 .sub_event(&MOUSE_HOVERED_EVENT);
100
101 close_cmd = POPUP_CLOSE_CMD.scoped(WIDGET.id()).subscribe(false);
102
103 let has_open = super::OPEN_SUBMENU_VAR.current_context();
104 if !has_open.capabilities().is_const() {
105 let handle = is_open.hook(move |v| {
106 if *v.value() {
107 has_open.modify(|v| **v += 1);
108 } else {
109 has_open.modify(|v| **v -= 1);
110 }
111 true
112 });
113 WIDGET.push_var_handle(handle);
114 }
115 }
116 UiNodeOp::Deinit => {
117 if let Some(v) = open.take() {
118 POPUP.force_close(&v);
119 is_open.set(false);
120 }
121 close_cmd = CommandHandle::dummy();
122 open_timer = None;
123 close_timer = None;
124 }
125 UiNodeOp::Info { info } => {
126 info.set_meta(
127 *SUB_MENU_INFO_ID,
128 SubMenuInfo {
129 parent: SUB_MENU_PARENT_CTX.get_clone(),
130 is_open: is_open.clone(),
131 },
132 );
133 }
134 UiNodeOp::Event { update } => {
135 if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
136 if args.is_mouse_enter_enabled() {
137 let info = WIDGET.info();
138
139 let is_root = info.submenu_parent().is_none();
140 let is_open = is_open.get();
141
142 if is_root {
143 if !is_open
147 && let Some(menu) = info.menu()
148 && let Some(focused) = FOCUS.focused().get()
149 {
150 let is_menu_focused = focused.contains(menu.id());
151
152 let mut focus_on_hover = is_menu_focused;
153 if !focus_on_hover
154 && let Some(focused) = info.tree().get(focused.widget_id())
155 && let Some(f_menu) = focused.menu()
156 {
157 focus_on_hover = f_menu.id() == menu.id();
159 }
160
161 if focus_on_hover {
162 FOCUS.focus_widget(WIDGET.id(), false);
164 }
165 }
166 } else if !is_open && open_timer.is_none() {
167 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
169 t.subscribe(UpdateOp::Update, WIDGET.id()).perm();
170 open_timer = Some(t);
171 }
172 } else if args.is_mouse_leave_enabled() {
173 open_timer = None;
174 }
175 } else if let Some(args) = KEY_INPUT_EVENT.on_unhandled(update) {
176 if let KeyState::Pressed = args.state
177 && args.target.contains_enabled(WIDGET.id())
178 && !is_open.get()
179 {
180 if let Some(info) = WIDGET.info().into_focusable(true, true) {
181 if info.info().submenu_parent().is_none() {
182 if matches!(&args.key, Key::ArrowUp | Key::ArrowDown) {
184 open_pop = info.focusable_down().is_none() && info.focusable_up().is_none();
185 } else if matches!(&args.key, Key::ArrowLeft | Key::ArrowRight) {
186 open_pop = info.focusable_left().is_none() && info.focusable_right().is_none();
187 }
188 } else {
189 match DIRECTION_VAR.get() {
191 LayoutDirection::LTR => open_pop = matches!(&args.key, Key::ArrowRight),
192 LayoutDirection::RTL => open_pop = matches!(&args.key, Key::ArrowLeft),
193 }
194 }
195 }
196
197 if open_pop {
198 args.propagation().stop();
199 }
200 }
201 } else if let Some(args) = FOCUS_CHANGED_EVENT.on(update) {
202 if args.is_focus_enter_enabled(WIDGET.id()) {
203 close_timer = None;
204 if !is_open.get() {
205 let info = WIDGET.info();
208 if info.submenu_parent().is_none() && let Some(prev_root) = args
210 .prev_focus
211 .as_ref()
212 .and_then(|p| info.tree().get(p.widget_id()))
213 .and_then(|w| w.submenu_root()) && prev_root.is_submenu_open().map(|v| v.get()).unwrap_or(false)
215 && let Some(prev_menu) = prev_root.menu()
216 && let Some(our_menu) = info.menu()
217 {
218 open_pop = our_menu.id() == prev_menu.id();
220 }
221 }
222 } else if args.is_focus_leave_enabled(WIDGET.id())
223 && is_open.get()
224 && let Some(f) = &args.new_focus
225 && let Some(f) = WINDOW.info().get(f.widget_id())
226 {
227 let id = WIDGET.id();
228 if !f.submenu_ancestors().any(|s| s.id() == id) {
229 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
235 t.subscribe(UpdateOp::Update, id).perm();
236 close_timer = Some(t);
237 }
238 }
239 } else if let Some(args) = CLICK_EVENT.on(update) {
240 if args.is_primary() && args.target.contains_enabled(WIDGET.id()) {
241 args.propagation().stop();
242
243 open_pop = if let Some(s) = open.take() {
245 let closed = matches!(s.get(), PopupState::Closed);
246 if !closed {
247 if WIDGET.info().submenu_parent().is_none() {
248 POPUP.force_close(&s);
250 FOCUS.focus_exit();
251 is_open.set(false);
252 close_cmd.set_enabled(false);
253 } else {
254 open = Some(s);
256 }
257 }
258 closed
259 } else {
260 true
261 };
262 if !open_pop && open.is_none() {
263 is_open.set(false);
264 }
265 }
266 } else if let Some(_args) = POPUP_CLOSE_CMD.scoped(WIDGET.id()).on(update)
267 && let Some(s) = open.take()
268 && !matches!(s.get(), PopupState::Closed)
269 {
270 POPUP.force_close(&s);
271 is_open.set(false);
272 close_cmd.set_enabled(false);
273 }
274 }
275 UiNodeOp::Update { .. } => {
276 if let Some(s) = &open {
277 if matches!(s.get(), PopupState::Closed) {
278 is_open.set(false);
279 close_cmd.set_enabled(false);
280 close_timer = None;
281 open = None;
282 } else if let Some(t) = &close_timer
283 && t.get().has_elapsed()
284 {
285 if let Some(s) = open.take()
286 && !matches!(s.get(), PopupState::Closed)
287 {
288 POPUP.force_close(&s);
289 is_open.set(false);
290 close_cmd.set_enabled(false);
291 }
292 close_timer = None;
293 }
294 } else if let Some(t) = &open_timer
295 && t.get().has_elapsed()
296 {
297 open_pop = true;
298 }
299 }
300 _ => {}
301 }
302 if open_pop {
303 let pop = super::popup::SubMenuPopup! {
304 parent_id = WIDGET.id();
305 children = children.take_on_init();
306 };
307 let state = POPUP.open(pop);
308 state.subscribe(UpdateOp::Update, WIDGET.id()).perm();
309 if !matches!(state.get(), PopupState::Closed) {
310 is_open.set(true);
311 close_cmd.set_enabled(true);
312 }
313 open = Some(state);
314 open_timer = None;
315 }
316 })
317}
318
319#[property(CHILD, default(FillUiNode), widget_impl(SubMenu))]
321pub fn header(wgt: &mut WidgetBuilding, child: impl IntoUiNode) {
322 let _ = child;
323 wgt.expect_property_capture();
324}
325
326#[property(CONTEXT, default(START_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
330pub fn start_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
331 with_context_var(child, START_COLUMN_WIDTH_VAR, width)
332}
333
334#[property(CONTEXT, default(END_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
338pub fn end_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
339 with_context_var(child, END_COLUMN_WIDTH_VAR, width)
340}
341
342#[property(FILL)]
353pub fn start_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
354 let cell = width(cell, START_COLUMN_WIDTH_VAR);
355 let cell = align(cell, Align::FILL_START);
356 background(child, cell)
357}
358
359#[property(FILL)]
370pub fn end_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
371 let cell = width(cell, END_COLUMN_WIDTH_VAR);
372 let cell = align(cell, Align::FILL_END);
373 background(child, cell)
374}
375
376#[property(FILL)]
385pub fn start_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
386 start_column(child, presenter((), cell_fn))
387}
388
389#[property(FILL)]
398pub fn end_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
399 end_column(child, presenter((), cell_fn))
400}
401
402#[property(CHILD_LAYOUT, default(false))]
409pub fn column_width_padding(child: impl IntoUiNode, enabled: impl IntoVar<bool>) -> UiNode {
410 let spacing = merge_var!(
411 START_COLUMN_WIDTH_VAR,
412 END_COLUMN_WIDTH_VAR,
413 DIRECTION_VAR,
414 enabled.into_var(),
415 |s, e, d, enabled| {
416 if *enabled {
417 let s = s.clone();
418 let e = e.clone();
419 if d.is_ltr() {
420 SideOffsets::new(0, e, 0, s)
421 } else {
422 SideOffsets::new(0, s, 0, e)
423 }
424 } else {
425 SideOffsets::zero()
426 }
427 }
428 );
429 padding(child, spacing)
430}
431
432context_var! {
433 pub static START_COLUMN_WIDTH_VAR: Length = 32;
435
436 pub static END_COLUMN_WIDTH_VAR: Length = 24;
438
439 pub static HOVER_OPEN_DELAY_VAR: Duration = 150.ms();
443
444 static IS_OPEN_VAR: bool = false;
445}
446
447#[property(EVENT, widget_impl(SubMenu, DefaultStyle))]
449pub fn is_open(child: impl IntoUiNode, state: impl IntoVar<bool>) -> UiNode {
450 bind_state(child, IS_OPEN_VAR, state)
451}
452
453#[property(CONTEXT, default(HOVER_OPEN_DELAY_VAR), widget_impl(SubMenu, DefaultStyle))]
459pub fn hover_open_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
460 with_context_var(child, HOVER_OPEN_DELAY_VAR, delay)
461}
462
463#[widget($crate::sub::DefaultStyle)]
468pub struct DefaultStyle(Style);
469impl DefaultStyle {
470 fn widget_intrinsic(&mut self) {
471 widget_set! {
472 self;
473
474 replace = true;
475
476 padding = (4, 10);
477 opacity = 90.pct();
478 foreground_highlight = unset!;
479
480 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| match d {
481 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
482 place: Point::bottom_left(),
483 origin: Point::top_left(),
484 }),
485 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
486 place: Point::bottom_right(),
487 origin: Point::top_right(),
488 }),
489 });
490
491 zng_wgt_button::style_fn = style_fn!(|_| ButtonStyle!());
492 zng_wgt_toggle::style_fn = style_fn!(|_| ToggleStyle!());
493 zng_wgt_rule_line::hr::color = BASE_COLOR_VAR.shade(1);
494 zng_wgt_rule_line::vr::color = BASE_COLOR_VAR.shade(1);
495 zng_wgt_rule_line::vr::height = 1.em();
496 zng_wgt_text::icon::ico_size = 18;
497
498 when *#is_hovered || *#is_focused || *#is_open {
499 background_color = BASE_COLOR_VAR.shade(1);
500 opacity = 100.pct();
501 }
502
503 when *#is_disabled {
504 saturate = false;
505 opacity = 50.pct();
506 cursor = CursorIcon::NotAllowed;
507 }
508 }
509 }
510}
511
512#[widget($crate::sub::SubMenuStyle)]
516pub struct SubMenuStyle(ButtonStyle);
517impl SubMenuStyle {
518 fn widget_intrinsic(&mut self) {
519 widget_set! {
520 self;
521
522 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| {
523 match d {
524 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
525 place: Point::top_right(),
526 origin: Point::top_left(),
527 }),
528 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
529 place: Point::top_left(),
530 origin: Point::top_right(),
531 }),
532 }
533 .with_min_size(AnchorSize::Unbounded)
534 });
535
536 when *#is_open {
537 background_color = BASE_COLOR_VAR.shade(1);
538 opacity = 100.pct();
539 }
540
541 end_column_fn = wgt_fn!(|_| zng_wgt_text::Text! {
542 size = 1.2.em();
543 font_family = FontNames::system_ui(&lang!(und));
544 align = Align::CENTER;
545
546 txt = "⏵";
547 when *#is_rtl {
548 txt = "⏴";
549 }
550 });
551 }
552 }
553}
554
555pub trait SubMenuWidgetInfoExt {
559 fn is_submenu(&self) -> bool;
563
564 fn is_submenu_open(&self) -> Option<Var<bool>>;
566
567 fn submenu_parent(&self) -> Option<WidgetInfo>;
575
576 fn submenu_ancestors(&self) -> SubMenuAncestors;
578 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors;
580
581 fn submenu_root(&self) -> Option<WidgetInfo>;
583
584 fn menu(&self) -> Option<WidgetInfo>;
588}
589impl SubMenuWidgetInfoExt for WidgetInfo {
590 fn is_submenu(&self) -> bool {
591 self.meta().contains(*SUB_MENU_INFO_ID)
592 }
593
594 fn is_submenu_open(&self) -> Option<Var<bool>> {
595 self.meta().get(*SUB_MENU_INFO_ID).map(|s| s.is_open.read_only())
596 }
597
598 fn submenu_parent(&self) -> Option<WidgetInfo> {
599 if let Some(p) = self.meta().get(*SUB_MENU_INFO_ID) {
600 self.tree().get(p.parent?)
601 } else if let Some(p) = self.ancestors().find(|a| a.is_submenu()) {
602 Some(p)
603 } else if let Some(pop) = self.meta().get(*SUB_MENU_POPUP_ID) {
604 self.tree().get(pop.parent?)
605 } else {
606 for anc in self.ancestors() {
607 if let Some(pop) = anc.meta().get(*SUB_MENU_POPUP_ID) {
608 if let Some(p) = pop.parent {
609 return self.tree().get(p);
610 } else {
611 return Some(anc);
613 }
614 }
615 }
616 None
617 }
618 }
619
620 fn submenu_ancestors(&self) -> SubMenuAncestors {
621 SubMenuAncestors {
622 node: self.submenu_parent(),
623 }
624 }
625
626 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors {
627 if self.is_submenu() {
628 SubMenuAncestors { node: Some(self.clone()) }
629 } else {
630 self.submenu_ancestors()
631 }
632 }
633
634 fn submenu_root(&self) -> Option<WidgetInfo> {
635 self.submenu_ancestors().last()
636 }
637
638 fn menu(&self) -> Option<WidgetInfo> {
639 let root = self
640 .submenu_root()
641 .or_else(|| if self.is_submenu() { Some(self.clone()) } else { None })?;
642
643 let scope = root.into_focus_info(true, true).scope()?;
644
645 if !scope.is_alt_scope() {
646 return None;
647 }
648
649 Some(scope.info().clone())
650 }
651}
652
653pub struct SubMenuAncestors {
659 node: Option<WidgetInfo>,
660}
661impl Iterator for SubMenuAncestors {
662 type Item = WidgetInfo;
663
664 fn next(&mut self) -> Option<Self::Item> {
665 if let Some(n) = self.node.take() {
666 self.node = n.submenu_parent();
667 Some(n)
668 } else {
669 None
670 }
671 }
672}
673
674pub(super) struct SubMenuInfo {
675 pub parent: Option<WidgetId>,
676 pub is_open: Var<bool>,
677}
678
679pub(super) struct SubMenuPopupInfo {
680 pub parent: Option<WidgetId>,
681}
682
683context_local! {
684 pub(super) static SUB_MENU_PARENT_CTX: Option<WidgetId> = None;
686}
687
688static_id! {
689 pub(super) static ref SUB_MENU_INFO_ID: StateId<SubMenuInfo>;
690 pub(super) static ref SUB_MENU_POPUP_ID: StateId<SubMenuPopupInfo>;
691}
692
693#[widget($crate::sub::ButtonStyle)]
701pub struct ButtonStyle(Style);
702impl ButtonStyle {
703 fn widget_intrinsic(&mut self) {
704 widget_set! {
705 self;
706 replace = true;
707
708 column_width_padding = true;
709 padding = (4, 0);
710 child_align = Align::START;
711
712 base_color = light_dark(rgb(0.82, 0.82, 0.82), rgb(0.18, 0.18, 0.18));
713 background_color = BASE_COLOR_VAR.rgba();
714 opacity = 90.pct();
715 foreground_highlight = unset!;
716 zng_wgt_tooltip::tooltip_fn = WidgetFn::nil(); click_mode = ClickMode::release();access_role = AccessRole::MenuItem;
721
722 on_pre_mouse_enter = hn!(|_| {
723 FOCUS.focus_widget(WIDGET.id(), false);
724 });
725
726 shortcut_txt = ShortcutText! {
727 shortcut = BUTTON.cmd().flat_map(|c| match c {
728 Some(c) => c.shortcut(),
729 None => const_var(Shortcuts::default()),
730 });
731 align = Align::CENTER;
732 };
733
734 icon_fn = BUTTON.cmd().flat_map(|c| match c {
735 Some(c) => c.icon(),
736 None => const_var(WidgetFn::nil()),
737 });
738
739 when *#is_focused {
740 background_color = BASE_COLOR_VAR.shade(1);
741 opacity = 100.pct();
742 }
743
744 when *#is_disabled {
745 saturate = false;
746 opacity = 50.pct();
747 cursor = CursorIcon::NotAllowed;
748 }
749
750 when *#is_mobile {
751 shortcut_txt = UiNode::nil();
752 }
753 }
754 }
755}
756
757#[widget($crate::sub::TouchButtonStyle)]
765pub struct TouchButtonStyle(Style);
766impl TouchButtonStyle {
767 fn widget_intrinsic(&mut self) {
768 widget_set! {
769 self;
770 zng_wgt::corner_radius = 0;
771 zng_wgt::visibility =
772 BUTTON
773 .cmd()
774 .flat_map(|c| match c {
775 Some(c) => c.is_enabled(),
776 None => const_var(true),
777 })
778 .map_into(),
779 ;
780 }
781 }
782}
783
784#[widget($crate::sub::ToggleStyle)]
792pub struct ToggleStyle(ButtonStyle);
793impl ToggleStyle {
794 fn widget_intrinsic(&mut self) {
795 widget_set! {
796 self;
797 replace = true;
798
799 click_mode = ClickMode::release();
800 access_role = AccessRole::MenuItemCheckBox;
801
802 start_column_fn = wgt_fn!(|_| Text! {
803 size = 1.2.em();
804 font_family = FontNames::system_ui(&lang!(und));
805 align = Align::CENTER;
806
807 txt = "✓";
808 when #{zng_wgt_toggle::IS_CHECKED_VAR}.is_none() {
809 txt = "━";
810 }
811
812 font_color = zng_wgt_text::FONT_COLOR_VAR.map(|c| c.transparent());
813 when #{zng_wgt_toggle::IS_CHECKED_VAR}.unwrap_or(true) {
814 font_color = zng_wgt_text::FONT_COLOR_VAR;
815 }
816 });
817 }
818 }
819}