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::<DeadlineVar>;
86 let mut close_timer = None::<DeadlineVar>;
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
135 UiNodeOp::Update { .. } => {
136 if let Some(s) = &open {
137 if matches!(s.get(), PopupState::Closed) {
138 is_open.set(false);
139 close_cmd.enabled().set(false);
140 close_timer = None;
141 open = None;
142 } else if let Some(t) = &close_timer
143 && t.get().has_elapsed()
144 {
145 if let Some(s) = open.take()
146 && !matches!(s.get(), PopupState::Closed)
147 {
148 POPUP.force_close(&s);
149 is_open.set(false);
150 close_cmd.enabled().set(false);
151 }
152 close_timer = None;
153 }
154 } else if let Some(t) = &open_timer
155 && t.get().has_elapsed()
156 {
157 open_pop = true;
158 }
159
160 MOUSE_HOVERED_EVENT.each_update(true, |args| {
161 let wgt = (WINDOW.id(), WIDGET.id());
162 if args.is_mouse_enter_enabled(wgt) {
163 let info = WIDGET.info();
164
165 let is_root = info.submenu_parent().is_none();
166 let is_open = is_open.get();
167
168 if is_root {
169 if !is_open
173 && let Some(menu) = info.menu()
174 && let Some(focused) = FOCUS.focused().get()
175 {
176 let is_menu_focused = focused.contains(menu.id());
177
178 let mut focus_on_hover = is_menu_focused;
179 if !focus_on_hover
180 && let Some(focused) = info.tree().get(focused.widget_id())
181 && let Some(f_menu) = focused.menu()
182 {
183 focus_on_hover = f_menu.id() == menu.id();
185 }
186
187 if focus_on_hover {
188 FOCUS.focus_widget(WIDGET.id(), false);
190 }
191 }
192 } else if !is_open && open_timer.is_none() {
193 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
195 t.subscribe(UpdateOp::Update, WIDGET.id()).perm();
196 open_timer = Some(t);
197 }
198 } else if args.is_mouse_leave_enabled(wgt) {
199 open_timer = None;
200 }
201 });
202 KEY_INPUT_EVENT.each_update(true, |args| {
203 if let KeyState::Pressed = args.state
204 && args.target.contains_enabled(WIDGET.id())
205 && !is_open.get()
206 {
207 if let Some(info) = WIDGET.info().into_focusable(true, true) {
208 if info.info().submenu_parent().is_none() {
209 if matches!(&args.key, Key::ArrowUp | Key::ArrowDown) {
211 open_pop = info.focusable_down().is_none() && info.focusable_up().is_none();
212 } else if matches!(&args.key, Key::ArrowLeft | Key::ArrowRight) {
213 open_pop = info.focusable_left().is_none() && info.focusable_right().is_none();
214 }
215 } else {
216 match DIRECTION_VAR.get() {
218 LayoutDirection::LTR => open_pop = matches!(&args.key, Key::ArrowRight),
219 LayoutDirection::RTL => open_pop = matches!(&args.key, Key::ArrowLeft),
220 }
221 }
222 }
223
224 if open_pop {
225 args.propagation.stop();
226 }
227 }
228 });
229 FOCUS_CHANGED_EVENT.each_update(true, |args| {
230 if args.is_focus_enter_enabled(WIDGET.id()) {
231 close_timer = None;
232 if !is_open.get() {
233 let info = WIDGET.info();
236 if info.submenu_parent().is_none() && let Some(prev_root) = args
238 .prev_focus
239 .as_ref()
240 .and_then(|p| info.tree().get(p.widget_id()))
241 .and_then(|w| w.submenu_root()) && prev_root.is_submenu_open().map(|v| v.get()).unwrap_or(false)
243 && let Some(prev_menu) = prev_root.menu()
244 && let Some(our_menu) = info.menu()
245 {
246 open_pop = our_menu.id() == prev_menu.id();
248 }
249 }
250 } else if args.is_focus_leave_enabled(WIDGET.id())
251 && is_open.get()
252 && let Some(f) = &args.new_focus
253 && let Some(f) = WINDOW.info().get(f.widget_id())
254 {
255 let id = WIDGET.id();
256 if !f.submenu_ancestors().any(|s| s.id() == id) {
257 let t = TIMERS.deadline(HOVER_OPEN_DELAY_VAR.get());
263 t.subscribe(UpdateOp::Update, id).perm();
264 close_timer = Some(t);
265 }
266 }
267 });
268
269 CLICK_EVENT.each_update(true, |args| {
270 if args.is_primary() && args.target.contains_enabled(WIDGET.id()) {
271 args.propagation.stop();
272
273 open_pop = if let Some(s) = open.take() {
275 let closed = matches!(s.get(), PopupState::Closed);
276 if !closed {
277 if WIDGET.info().submenu_parent().is_none() {
278 POPUP.force_close(&s);
280 FOCUS.focus_exit(true);
281 is_open.set(false);
282 close_cmd.enabled().set(false);
283 } else {
284 open = Some(s);
286 }
287 }
288 closed
289 } else {
290 true
291 };
292 if !open_pop && open.is_none() {
293 is_open.set(false);
294 }
295 }
296 });
297
298 if POPUP_CLOSE_CMD.scoped(WIDGET.id()).has_update(true, true)
299 && let Some(s) = open.take()
300 && !matches!(s.get(), PopupState::Closed)
301 {
302 POPUP.force_close(&s);
303 is_open.set(false);
304 close_cmd.enabled().set(false);
305 }
306 }
307 _ => {}
308 }
309 if open_pop {
310 let pop = super::popup::SubMenuPopup! {
311 parent_id = WIDGET.id();
312 children = children.take_on_init();
313 };
314 let state = POPUP.open(pop);
315 state.subscribe(UpdateOp::Update, WIDGET.id()).perm();
316 if !matches!(state.get(), PopupState::Closed) {
317 is_open.set(true);
318 close_cmd.enabled().set(true);
319 }
320 open = Some(state);
321 open_timer = None;
322 }
323 })
324}
325
326#[property(CHILD, default(FillUiNode), widget_impl(SubMenu))]
328pub fn header(wgt: &mut WidgetBuilding, child: impl IntoUiNode) {
329 let _ = child;
330 wgt.expect_property_capture();
331}
332
333#[property(CONTEXT, default(START_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
337pub fn start_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
338 with_context_var(child, START_COLUMN_WIDTH_VAR, width)
339}
340
341#[property(CONTEXT, default(END_COLUMN_WIDTH_VAR), widget_impl(SubMenu, DefaultStyle))]
345pub fn end_column_width(child: impl IntoUiNode, width: impl IntoVar<Length>) -> UiNode {
346 with_context_var(child, END_COLUMN_WIDTH_VAR, width)
347}
348
349#[property(FILL)]
360pub fn start_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
361 let cell = width(cell, START_COLUMN_WIDTH_VAR);
362 let cell = align(cell, Align::FILL_START);
363 background(child, cell)
364}
365
366#[property(FILL)]
377pub fn end_column(child: impl IntoUiNode, cell: impl IntoUiNode) -> UiNode {
378 let cell = width(cell, END_COLUMN_WIDTH_VAR);
379 let cell = align(cell, Align::FILL_END);
380 background(child, cell)
381}
382
383#[property(FILL)]
392pub fn start_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
393 start_column(child, presenter((), cell_fn))
394}
395
396#[property(FILL)]
405pub fn end_column_fn(child: impl IntoUiNode, cell_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
406 end_column(child, presenter((), cell_fn))
407}
408
409#[property(CHILD_LAYOUT, default(false))]
416pub fn column_width_padding(child: impl IntoUiNode, enabled: impl IntoVar<bool>) -> UiNode {
417 let spacing = merge_var!(
418 START_COLUMN_WIDTH_VAR,
419 END_COLUMN_WIDTH_VAR,
420 DIRECTION_VAR,
421 enabled.into_var(),
422 |s, e, d, enabled| {
423 if *enabled {
424 let s = s.clone();
425 let e = e.clone();
426 if d.is_ltr() {
427 SideOffsets::new(0, e, 0, s)
428 } else {
429 SideOffsets::new(0, s, 0, e)
430 }
431 } else {
432 SideOffsets::zero()
433 }
434 }
435 );
436 padding(child, spacing)
437}
438
439context_var! {
440 pub static START_COLUMN_WIDTH_VAR: Length = 32;
442
443 pub static END_COLUMN_WIDTH_VAR: Length = 24;
445
446 pub static HOVER_OPEN_DELAY_VAR: Duration = 150.ms();
450
451 static IS_OPEN_VAR: bool = false;
452}
453
454#[property(EVENT, widget_impl(SubMenu, DefaultStyle))]
456pub fn is_open(child: impl IntoUiNode, state: impl IntoVar<bool>) -> UiNode {
457 bind_state(child, IS_OPEN_VAR, state)
458}
459
460#[property(CONTEXT, default(HOVER_OPEN_DELAY_VAR), widget_impl(SubMenu, DefaultStyle))]
466pub fn hover_open_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
467 with_context_var(child, HOVER_OPEN_DELAY_VAR, delay)
468}
469
470#[widget($crate::sub::DefaultStyle)]
475pub struct DefaultStyle(Style);
476impl DefaultStyle {
477 fn widget_intrinsic(&mut self) {
478 widget_set! {
479 self;
480
481 replace = true;
482
483 padding = (4, 10);
484 opacity = 90.pct();
485 foreground_highlight = unset!;
486
487 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| match d {
488 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
489 place: Point::bottom_left(),
490 origin: Point::top_left(),
491 }),
492 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
493 place: Point::bottom_right(),
494 origin: Point::top_right(),
495 }),
496 });
497
498 zng_wgt_button::style_fn = style_fn!(|_| ButtonStyle!());
499 zng_wgt_toggle::style_fn = style_fn!(|_| ToggleStyle!());
500 zng_wgt_rule_line::hr::color = BASE_COLOR_VAR.shade(1);
501 zng_wgt_rule_line::vr::color = BASE_COLOR_VAR.shade(1);
502 zng_wgt_rule_line::vr::height = 1.em();
503 zng_wgt_text::icon::ico_size = 18;
504
505 when *#is_hovered || *#is_focused || *#is_open {
506 background_color = BASE_COLOR_VAR.shade(1);
507 opacity = 100.pct();
508 }
509
510 when *#is_disabled {
511 saturate = false;
512 opacity = 50.pct();
513 cursor = CursorIcon::NotAllowed;
514 }
515 }
516 }
517}
518
519#[widget($crate::sub::SubMenuStyle)]
523pub struct SubMenuStyle(ButtonStyle);
524impl SubMenuStyle {
525 fn widget_intrinsic(&mut self) {
526 widget_set! {
527 self;
528
529 zng_wgt_layer::popup::anchor_mode = DIRECTION_VAR.map(|d| {
530 match d {
531 LayoutDirection::LTR => AnchorMode::popup(AnchorOffset {
532 place: Point::top_right(),
533 origin: Point::top_left(),
534 }),
535 LayoutDirection::RTL => AnchorMode::popup(AnchorOffset {
536 place: Point::top_left(),
537 origin: Point::top_right(),
538 }),
539 }
540 .with_min_size(AnchorSize::Unbounded)
541 });
542
543 when *#is_open {
544 background_color = BASE_COLOR_VAR.shade(1);
545 opacity = 100.pct();
546 }
547
548 end_column_fn = wgt_fn!(|_| zng_wgt_text::Text! {
549 size = 1.2.em();
550 font_family = FontNames::system_ui(&lang!(und));
551 align = Align::CENTER;
552
553 txt = "⏵";
554 when *#is_rtl {
555 txt = "⏴";
556 }
557 });
558 }
559 }
560}
561
562pub trait SubMenuWidgetInfoExt {
566 fn is_submenu(&self) -> bool;
570
571 fn is_submenu_open(&self) -> Option<Var<bool>>;
573
574 fn submenu_parent(&self) -> Option<WidgetInfo>;
582
583 fn submenu_ancestors(&self) -> SubMenuAncestors;
585 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors;
587
588 fn submenu_root(&self) -> Option<WidgetInfo>;
590
591 fn menu(&self) -> Option<WidgetInfo>;
595}
596impl SubMenuWidgetInfoExt for WidgetInfo {
597 fn is_submenu(&self) -> bool {
598 self.meta().contains(*SUB_MENU_INFO_ID)
599 }
600
601 fn is_submenu_open(&self) -> Option<Var<bool>> {
602 self.meta().get(*SUB_MENU_INFO_ID).map(|s| s.is_open.read_only())
603 }
604
605 fn submenu_parent(&self) -> Option<WidgetInfo> {
606 if let Some(p) = self.meta().get(*SUB_MENU_INFO_ID) {
607 self.tree().get(p.parent?)
608 } else if let Some(p) = self.ancestors().find(|a| a.is_submenu()) {
609 Some(p)
610 } else if let Some(pop) = self.meta().get(*SUB_MENU_POPUP_ID) {
611 self.tree().get(pop.parent?)
612 } else {
613 for anc in self.ancestors() {
614 if let Some(pop) = anc.meta().get(*SUB_MENU_POPUP_ID) {
615 if let Some(p) = pop.parent {
616 return self.tree().get(p);
617 } else {
618 return Some(anc);
620 }
621 }
622 }
623 None
624 }
625 }
626
627 fn submenu_ancestors(&self) -> SubMenuAncestors {
628 SubMenuAncestors {
629 node: self.submenu_parent(),
630 }
631 }
632
633 fn submenu_self_and_ancestors(&self) -> SubMenuAncestors {
634 if self.is_submenu() {
635 SubMenuAncestors { node: Some(self.clone()) }
636 } else {
637 self.submenu_ancestors()
638 }
639 }
640
641 fn submenu_root(&self) -> Option<WidgetInfo> {
642 self.submenu_ancestors().last()
643 }
644
645 fn menu(&self) -> Option<WidgetInfo> {
646 let root = self
647 .submenu_root()
648 .or_else(|| if self.is_submenu() { Some(self.clone()) } else { None })?;
649
650 let scope = root.into_focus_info(true, true).scope()?;
651
652 if !scope.is_alt_scope() {
653 return None;
654 }
655
656 Some(scope.info().clone())
657 }
658}
659
660pub struct SubMenuAncestors {
666 node: Option<WidgetInfo>,
667}
668impl Iterator for SubMenuAncestors {
669 type Item = WidgetInfo;
670
671 fn next(&mut self) -> Option<Self::Item> {
672 if let Some(n) = self.node.take() {
673 self.node = n.submenu_parent();
674 Some(n)
675 } else {
676 None
677 }
678 }
679}
680
681pub(super) struct SubMenuInfo {
682 pub parent: Option<WidgetId>,
683 pub is_open: Var<bool>,
684}
685
686pub(super) struct SubMenuPopupInfo {
687 pub parent: Option<WidgetId>,
688}
689
690context_local! {
691 pub(super) static SUB_MENU_PARENT_CTX: Option<WidgetId> = None;
693}
694
695static_id! {
696 pub(super) static ref SUB_MENU_INFO_ID: StateId<SubMenuInfo>;
697 pub(super) static ref SUB_MENU_POPUP_ID: StateId<SubMenuPopupInfo>;
698}
699
700#[widget($crate::sub::ButtonStyle)]
708pub struct ButtonStyle(Style);
709impl ButtonStyle {
710 fn widget_intrinsic(&mut self) {
711 widget_set! {
712 self;
713 replace = true;
714
715 column_width_padding = true;
716 padding = (4, 0);
717 child_align = Align::START;
718
719 base_color = light_dark(rgb(0.82, 0.82, 0.82), rgb(0.18, 0.18, 0.18));
720 background_color = BASE_COLOR_VAR.rgba();
721 opacity = 90.pct();
722 foreground_highlight = unset!;
723 zng_wgt_tooltip::tooltip_fn = WidgetFn::nil(); click_mode = ClickMode::release();access_role = AccessRole::MenuItem;
728
729 on_pre_mouse_enter = hn!(|_| {
730 FOCUS.focus_widget(WIDGET.id(), false);
731 });
732
733 shortcut_txt = ShortcutText! {
734 shortcut = BUTTON.cmd().flat_map(|c| match c {
735 Some(c) => c.shortcut(),
736 None => const_var(Shortcuts::default()),
737 });
738 align = Align::CENTER;
739 };
740
741 icon_fn = BUTTON.cmd().flat_map(|c| match c {
742 Some(c) => c.icon(),
743 None => const_var(WidgetFn::nil()),
744 });
745
746 when *#is_focused {
747 background_color = BASE_COLOR_VAR.shade(1);
748 opacity = 100.pct();
749 }
750
751 when *#is_disabled {
752 saturate = false;
753 opacity = 50.pct();
754 cursor = CursorIcon::NotAllowed;
755 }
756
757 when *#is_mobile {
758 shortcut_txt = UiNode::nil();
759 }
760 }
761 }
762}
763
764#[widget($crate::sub::TouchButtonStyle)]
772pub struct TouchButtonStyle(Style);
773impl TouchButtonStyle {
774 fn widget_intrinsic(&mut self) {
775 widget_set! {
776 self;
777 zng_wgt::corner_radius = 0;
778 zng_wgt::visibility =
779 BUTTON
780 .cmd()
781 .flat_map(|c| match c {
782 Some(c) => c.is_enabled(),
783 None => const_var(true),
784 })
785 .map_into(),
786 ;
787 }
788 }
789}
790
791#[widget($crate::sub::ToggleStyle)]
799pub struct ToggleStyle(ButtonStyle);
800impl ToggleStyle {
801 fn widget_intrinsic(&mut self) {
802 widget_set! {
803 self;
804 replace = true;
805
806 click_mode = ClickMode::release();
807 access_role = AccessRole::MenuItemCheckBox;
808
809 start_column_fn = wgt_fn!(|_| Text! {
810 size = 1.2.em();
811 font_family = FontNames::system_ui(&lang!(und));
812 align = Align::CENTER;
813
814 txt = "✓";
815 when #{zng_wgt_toggle::IS_CHECKED_VAR}.is_none() {
816 txt = "━";
817 }
818
819 font_color = zng_wgt_text::FONT_COLOR_VAR.map(|c| c.transparent());
820 when #{zng_wgt_toggle::IS_CHECKED_VAR}.unwrap_or(true) {
821 font_color = zng_wgt_text::FONT_COLOR_VAR;
822 }
823 });
824 }
825 }
826}