use std::cell::OnceCell;
use std::sync::Arc;
use blinc_animation::AnimationPreset;
use blinc_core::context_state::BlincContextState;
use blinc_core::{Color, State};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::motion::motion_derived;
use blinc_layout::overlay_state::get_overlay_manager;
use blinc_layout::prelude::*;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::widgets::hr::hr_with_bg;
use blinc_layout::widgets::overlay::{OverlayAnimation, OverlayHandle, OverlayManagerExt};
use blinc_layout::InstanceKey;
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
use super::context_menu::{ContextMenuItem, SubmenuBuilder};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MenuTriggerMode {
#[default]
Click,
Hover,
}
#[derive(Clone)]
pub enum MenubarTrigger {
Label(String),
Custom(Arc<dyn Fn(bool) -> Div + Send + Sync>),
}
impl std::fmt::Debug for MenubarTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MenubarTrigger::Label(s) => write!(f, "Label({:?})", s),
MenubarTrigger::Custom(_) => write!(f, "Custom(...)"),
}
}
}
#[derive(Clone)]
pub struct MenubarMenu {
trigger: MenubarTrigger,
items: Vec<ContextMenuItem>,
}
impl MenubarMenu {
pub fn new(label: impl Into<String>) -> Self {
Self {
trigger: MenubarTrigger::Label(label.into()),
items: Vec::new(),
}
}
pub fn new_custom<F>(trigger: F) -> Self
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
Self {
trigger: MenubarTrigger::Custom(Arc::new(trigger)),
items: Vec::new(),
}
}
pub fn item<F>(mut self, label: impl Into<String>, on_click: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.items
.push(ContextMenuItem::new(label).on_click(on_click));
self
}
pub fn item_with_shortcut<F>(
mut self,
label: impl Into<String>,
shortcut: impl Into<String>,
on_click: F,
) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.items.push(
ContextMenuItem::new(label)
.shortcut(shortcut)
.on_click(on_click),
);
self
}
pub fn item_with_icon<F>(
mut self,
label: impl Into<String>,
icon_svg: impl Into<String>,
on_click: F,
) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.items.push(
ContextMenuItem::new(label)
.icon(icon_svg)
.on_click(on_click),
);
self
}
pub fn item_disabled(mut self, label: impl Into<String>) -> Self {
self.items.push(ContextMenuItem::new(label).disabled());
self
}
pub fn separator(mut self) -> Self {
self.items.push(ContextMenuItem::separator());
self
}
pub fn submenu<F>(mut self, label: impl Into<String>, builder: F) -> Self
where
F: FnOnce(SubmenuBuilder) -> SubmenuBuilder,
{
let sub = builder(SubmenuBuilder::new_public());
self.items
.push(ContextMenuItem::new(label).submenu(sub.items()));
self
}
}
#[derive(Clone, Debug)]
pub struct MenuTriggerStyle {
pub px: f32,
pub py: f32,
pub font_size: f32,
pub hover_bg: Option<Color>,
pub radius: Option<f32>,
}
impl Default for MenuTriggerStyle {
fn default() -> Self {
Self {
px: 12.0,
py: 8.0,
font_size: 14.0,
hover_bg: None,
radius: None,
}
}
}
impl MenuTriggerStyle {
pub fn new() -> Self {
Self::default()
}
pub fn px(mut self, px: f32) -> Self {
self.px = px;
self
}
pub fn py(mut self, py: f32) -> Self {
self.py = py;
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = size;
self
}
pub fn hover_bg(mut self, color: Color) -> Self {
self.hover_bg = Some(color);
self
}
pub fn radius(mut self, radius: f32) -> Self {
self.radius = Some(radius);
self
}
}
pub struct MenubarBuilder {
menus: Vec<MenubarMenu>,
trigger_mode: MenuTriggerMode,
trigger_style: MenuTriggerStyle,
key: InstanceKey,
classes: Vec<String>,
user_id: Option<String>,
built: OnceCell<Menubar>,
}
impl std::fmt::Debug for MenubarBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenubarBuilder")
.field("menus", &self.menus.len())
.finish()
}
}
impl MenubarBuilder {
#[track_caller]
pub fn new() -> Self {
Self {
menus: Vec::new(),
trigger_mode: MenuTriggerMode::default(),
trigger_style: MenuTriggerStyle::default(),
key: InstanceKey::new("menubar"),
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn trigger_mode(mut self, mode: MenuTriggerMode) -> Self {
self.trigger_mode = mode;
self
}
pub fn trigger_style(mut self, style: MenuTriggerStyle) -> Self {
self.trigger_style = style;
self
}
pub fn menu<F>(mut self, label: impl Into<String>, builder: F) -> Self
where
F: FnOnce(MenubarMenu) -> MenubarMenu,
{
let menu = builder(MenubarMenu::new(label));
self.menus.push(menu);
self
}
pub fn menu_custom<T, F>(mut self, trigger: T, builder: F) -> Self
where
T: Fn(bool) -> Div + Send + Sync + 'static,
F: FnOnce(MenubarMenu) -> MenubarMenu,
{
let menu = builder(MenubarMenu::new_custom(trigger));
self.menus.push(menu);
self
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.classes.push(name.into());
self
}
pub fn id(mut self, id: &str) -> Self {
self.user_id = Some(id.to_string());
self
}
fn get_or_build(&self) -> &Menubar {
self.built.get_or_init(|| self.build_component())
}
fn build_component(&self) -> Menubar {
let theme = ThemeState::get();
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let active_menu: State<Option<usize>> =
BlincContextState::get().use_state_keyed(self.key.get(), || None);
let overlay_handle_state: State<Option<u64>> =
BlincContextState::get().use_state_keyed(&self.key.derive("handle"), || None);
let menus = self.menus.clone();
let key_base = self.key.get().to_string();
let trigger_mode = self.trigger_mode;
let trigger_style = self.trigger_style.clone();
let mut menubar = div()
.class("cn-menubar")
.flex_row()
.items_center()
.h_fit()
.px(4.0)
.bg(bg)
.border_bottom(1.0, border);
for (idx, menu) in menus.iter().enumerate() {
let menu_trigger = menu.trigger.clone();
let menu_items = menu.items.clone();
let menu_key = format!("{}_{}", key_base, idx);
let active_menu_for_trigger = active_menu.clone();
let active_menu_for_hover = active_menu.clone();
let overlay_handle_for_trigger = overlay_handle_state.clone();
let overlay_handle_for_show = overlay_handle_state.clone();
let overlay_handle_for_hover = overlay_handle_state.clone();
let menu_items_for_hover = menu_items.clone();
let menu_key_for_hover = menu_key.clone();
let menu_key_for_click = menu_key.clone();
let style_px = trigger_style.px;
let style_py = trigger_style.py;
let style_font_size = trigger_style.font_size;
let style_radius = trigger_style.radius;
let radius = style_radius.unwrap_or_else(|| theme.radius(RadiusToken::Sm));
let trigger_content: Div = match &menu_trigger {
MenubarTrigger::Label(label) => {
let text_col = theme.color(ColorToken::TextPrimary);
div()
.flex_row()
.items_center()
.rounded(radius)
.px(2.0)
.py(1.0)
.child(
text(label)
.size(style_font_size)
.color(text_col)
.no_cursor()
.pointer_events_none(),
)
}
MenubarTrigger::Custom(custom_fn) => div()
.flex_row()
.items_center()
.rounded(radius)
.child(custom_fn(false)),
};
let mut trigger = trigger_content
.class("cn-menubar-trigger")
.h_fit()
.px(style_px / 4.0)
.py(style_py / 4.0)
.cursor_pointer();
trigger = trigger.on_click(move |ctx| {
let current_active = active_menu_for_trigger.get();
let mgr = get_overlay_manager();
if current_active == Some(idx) {
if let Some(handle_id) = overlay_handle_for_trigger.get() {
mgr.close(OverlayHandle::from_raw(handle_id));
}
active_menu_for_trigger.set(None);
return;
}
if let Some(handle_id) = overlay_handle_for_trigger.get() {
let handle = OverlayHandle::from_raw(handle_id);
if !mgr.is_closing(handle) && !mgr.is_pending_close(handle) {
mgr.close(handle);
}
}
active_menu_for_trigger.set(Some(idx));
let x = ctx.bounds_x;
let y = ctx.bounds_y + ctx.bounds_height + 4.0;
let handle = show_menubar_dropdown(
x,
y,
&menu_items,
180.0, overlay_handle_for_show.clone(),
active_menu_for_trigger.clone(),
menu_key_for_click.clone(),
idx,
);
overlay_handle_for_show.set(Some(handle.id()));
});
if trigger_mode == MenuTriggerMode::Hover {
let overlay_handle_for_hover_leave = overlay_handle_state.clone();
trigger = trigger.on_hover_enter(move |ctx| {
let current_active = active_menu_for_hover.get();
let mgr = get_overlay_manager();
let full_motion_key = format!("motion:menubar_{}:child:0", menu_key_for_hover);
if current_active == Some(idx) {
if let Some(handle_id) = overlay_handle_for_hover.get() {
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_pending_close(handle) {
mgr.hover_enter(handle);
}
let motion = blinc_layout::selector::query_motion(&full_motion_key);
if motion.is_exiting() {
mgr.cancel_close(handle);
motion.cancel_exit();
}
}
return;
}
mgr.close_all_of(blinc_layout::widgets::overlay::OverlayKind::Tooltip);
active_menu_for_hover.set(Some(idx));
let x = ctx.bounds_x;
let y = ctx.bounds_y + ctx.bounds_height + 4.0;
let handle = show_menubar_hover_dropdown(
x,
y,
&menu_items_for_hover,
180.0,
overlay_handle_for_hover.clone(),
active_menu_for_hover.clone(),
menu_key_for_hover.clone(),
idx,
);
overlay_handle_for_hover.set(Some(handle.id()));
});
trigger = trigger.on_hover_leave(move |_| {
if let Some(handle_id) = overlay_handle_for_hover_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_visible(handle) && !mgr.is_pending_close(handle) {
mgr.hover_leave(handle);
}
}
});
}
menubar = menubar.child(trigger);
}
for c in &self.classes {
menubar = menubar.class(c);
}
if let Some(ref id) = self.user_id {
menubar = menubar.id(id);
}
Menubar { inner: menubar }
}
}
impl Default for MenubarBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(clippy::too_many_arguments)]
fn show_menubar_dropdown(
x: f32,
y: f32,
items: &[ContextMenuItem],
min_width: f32,
handle_state: State<Option<u64>>,
active_menu_state: State<Option<usize>>,
key: String,
menu_idx: usize,
) -> OverlayHandle {
let theme = ThemeState::get();
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let text_color = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let text_tertiary = theme.color(ColorToken::TextTertiary);
let radius = theme.radius(RadiusToken::Md);
let font_size = 14.0;
let padding = 12.0;
let items = items.to_vec();
let handle_state_for_content = handle_state.clone();
let active_menu_for_content = active_menu_state.clone();
let handle_state_for_close = handle_state.clone();
let active_menu_for_close = active_menu_state.clone();
let mgr = get_overlay_manager();
let motion_key_str = format!("menubar_{}", key);
mgr.dropdown()
.at(x, y)
.animation(OverlayAnimation::none())
.dismiss_on_escape(true)
.on_close(move || {
if active_menu_for_close.get() == Some(menu_idx) {
active_menu_for_close.set(None);
}
handle_state_for_close.set(None);
})
.content(move || {
build_menubar_dropdown_content(
&items,
min_width,
&handle_state_for_content,
&active_menu_for_content,
&motion_key_str,
bg,
border,
text_color,
text_secondary,
text_tertiary,
radius,
font_size,
padding,
)
})
.show()
}
#[allow(clippy::too_many_arguments)]
fn show_menubar_hover_dropdown(
x: f32,
y: f32,
items: &[ContextMenuItem],
min_width: f32,
handle_state: State<Option<u64>>,
active_menu_state: State<Option<usize>>,
key: String,
menu_idx: usize,
) -> OverlayHandle {
let theme = ThemeState::get();
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let text_color = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let text_tertiary = theme.color(ColorToken::TextTertiary);
let radius = theme.radius(RadiusToken::Md);
let font_size = 14.0;
let padding = 12.0;
let items = items.to_vec();
let handle_state_for_content = handle_state.clone();
let active_menu_for_content = active_menu_state.clone();
let handle_state_for_close = handle_state.clone();
let active_menu_for_close = active_menu_state.clone();
let handle_state_for_hover = handle_state.clone();
let mgr = get_overlay_manager();
let menu_key = key.clone();
mgr.hover_card()
.at(x, y)
.anchor_direction(blinc_layout::widgets::overlay::AnchorDirection::Bottom)
.animation(OverlayAnimation::none())
.dismiss_on_escape(true)
.on_close(move || {
if active_menu_for_close.get() == Some(menu_idx) {
active_menu_for_close.set(None);
}
handle_state_for_close.set(None);
})
.content(move || {
build_menubar_hover_dropdown_content(
&items,
min_width,
&handle_state_for_content,
&active_menu_for_content,
&handle_state_for_hover,
&menu_key,
bg,
border,
text_color,
text_secondary,
text_tertiary,
radius,
font_size,
padding,
)
})
.show()
}
#[allow(clippy::too_many_arguments)]
fn build_menubar_hover_dropdown_content(
items: &[ContextMenuItem],
width: f32,
overlay_handle_state: &State<Option<u64>>,
_active_menu_state: &State<Option<usize>>,
handle_state_for_hover: &State<Option<u64>>,
key: &str,
bg: Color,
border: Color,
text_color: Color,
text_secondary: Color,
text_tertiary: Color,
radius: f32,
font_size: f32,
padding: f32,
) -> Div {
let menu_id = key;
let submenu_handle: State<Option<u64>> =
BlincContextState::get().use_state_keyed(&format!("{}_submenu", key), || None);
let handle_state_for_enter = handle_state_for_hover.clone();
let handle_state_for_leave = handle_state_for_hover.clone();
let mut menu = div()
.id(menu_id)
.flex_col()
.w(width)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg()
.overflow_clip()
.h_fit()
.py(1.0)
.on_hover_enter(move |_| {
if let Some(handle_id) = handle_state_for_enter.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_pending_close(handle) {
mgr.hover_enter(handle); }
}
})
.on_hover_leave(move |_| {
if let Some(handle_id) = handle_state_for_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_visible(handle) && !mgr.is_pending_close(handle) {
mgr.hover_leave(handle);
}
}
});
for (idx, item) in items.iter().enumerate() {
if item.is_separator() {
menu = menu.child(hr_with_bg(bg));
} else {
let item_label = item.get_label().to_string();
let item_shortcut = item.get_shortcut().map(|s| s.to_string());
let item_icon = item.get_icon().map(|s| s.to_string());
let item_disabled = item.is_disabled();
let item_on_click = item.get_on_click();
let has_submenu = item.has_submenu();
let submenu_items = item.get_submenu().cloned();
let submenu_handle_for_hover = submenu_handle.clone();
let submenu_handle_for_leave = submenu_handle.clone();
let submenu_key = format!("{}_sub-{}", key, idx);
let item_text_color = if item_disabled {
text_tertiary
} else {
text_color
};
let shortcut_color = text_secondary;
let cursor = if item_disabled {
CursorStyle::NotAllowed
} else {
CursorStyle::Pointer
};
let mut left_side = div()
.w_fit()
.h_fit()
.flex_row()
.items_center()
.gap(padding / 4.0);
if let Some(ref icon_svg) = item_icon {
left_side = left_side.child(svg(icon_svg).size(16.0, 16.0).color(item_text_color));
}
left_side = left_side
.child(
text(&item_label)
.size(font_size)
.color(item_text_color)
.no_cursor()
.pointer_events_none(),
)
.pointer_events_none();
let right_side: Option<Div> = if let Some(ref shortcut) = item_shortcut {
Some(
div().child(
text(shortcut)
.size(font_size - 2.0)
.color(shortcut_color)
.no_cursor(),
),
)
} else if has_submenu {
let chevron_right = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>"#;
Some(
div()
.child(svg(chevron_right).size(12.0, 12.0).color(text_tertiary))
.pointer_events_none(),
)
} else {
None
};
let mut row_content = div()
.w_full()
.h_fit()
.flex_row()
.items_center()
.justify_between()
.child(left_side);
if let Some(right) = right_side {
row_content = row_content.child(right);
}
let mut row = row_content
.class("cn-menubar-item")
.w_full()
.h_fit()
.py(padding / 4.0)
.px(padding / 2.0)
.cursor(cursor)
.on_click(move |_| {
if !item_disabled && !has_submenu {
if let Some(ref cb) = item_on_click {
cb();
}
let mgr = get_overlay_manager();
mgr.close_all_of(blinc_layout::widgets::overlay::OverlayKind::Tooltip);
}
});
if has_submenu && !item_disabled {
let submenu_items_for_hover = submenu_items.clone();
let overlay_handle_for_submenu = overlay_handle_state.clone();
let submenu_key_for_hover = submenu_key.clone();
row = row.on_hover_enter(move |ctx| {
let mgr = get_overlay_manager();
if let Some(handle_id) = submenu_handle_for_hover.get() {
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
}
if let Some(ref items) = submenu_items_for_hover {
let x = ctx.bounds_x + ctx.bounds_width + 4.0;
let y = ctx.bounds_y;
let handle = show_menubar_submenu(
x,
y,
items,
160.0,
overlay_handle_for_submenu.clone(),
submenu_handle_for_hover.clone(),
submenu_key_for_hover.clone(),
);
submenu_handle_for_hover.set(Some(handle.id()));
}
});
} else {
row = row.on_hover_enter(move |_| {
if let Some(handle_id) = submenu_handle_for_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
submenu_handle_for_leave.set(None);
}
});
}
menu = menu.child(row);
}
}
div().child(menu)
}
fn show_menubar_submenu(
x: f32,
y: f32,
items: &[ContextMenuItem],
min_width: f32,
parent_handle_state: State<Option<u64>>,
submenu_handle_state: State<Option<u64>>,
key: String,
) -> OverlayHandle {
let theme = ThemeState::get();
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let text_color = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let text_tertiary = theme.color(ColorToken::TextTertiary);
let radius = theme.radius(RadiusToken::Md);
let font_size = 14.0;
let padding = 12.0;
let items = items.to_vec();
let submenu_handle_for_content = submenu_handle_state.clone();
let parent_handle_for_content = parent_handle_state.clone();
let submenu_handle_for_close = submenu_handle_state.clone();
let parent_handle_for_hover = parent_handle_state.clone();
let mgr = get_overlay_manager();
mgr.hover_card()
.at(x, y)
.anchor_direction(blinc_layout::widgets::overlay::AnchorDirection::Right)
.animation(OverlayAnimation::none())
.dismiss_on_escape(true)
.on_close(move || {
submenu_handle_for_close.set(None);
})
.content(move || {
build_menubar_submenu_content(
&items,
min_width,
&parent_handle_for_content,
&submenu_handle_for_content,
&parent_handle_for_hover,
&key,
bg,
border,
text_color,
text_secondary,
text_tertiary,
radius,
font_size,
padding,
)
})
.show()
}
#[allow(clippy::too_many_arguments)]
fn build_menubar_submenu_content(
items: &[ContextMenuItem],
width: f32,
parent_handle_state: &State<Option<u64>>,
submenu_handle_state: &State<Option<u64>>,
parent_menu_handle_state: &State<Option<u64>>,
key: &str,
bg: Color,
border: Color,
text_color: Color,
text_secondary: Color,
text_tertiary: Color,
radius: f32,
font_size: f32,
padding: f32,
) -> Div {
let menu_id = key;
let nested_submenu_handle: State<Option<u64>> =
BlincContextState::get().use_state_keyed(&format!("{}_nested", key), || None);
let submenu_handle_for_enter = submenu_handle_state.clone();
let submenu_handle_for_leave = submenu_handle_state.clone();
let parent_handle_for_enter = parent_menu_handle_state.clone();
let mut menu = div()
.id(menu_id)
.flex_col()
.w(width)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg()
.overflow_clip()
.h_fit()
.py(1.0)
.on_hover_enter(move |_| {
let mgr = get_overlay_manager();
if let Some(handle_id) = submenu_handle_for_enter.get() {
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_pending_close(handle) {
mgr.hover_enter(handle);
}
}
if let Some(handle_id) = parent_handle_for_enter.get() {
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_pending_close(handle) {
mgr.hover_enter(handle);
}
}
})
.on_hover_leave(move |_| {
if let Some(handle_id) = submenu_handle_for_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
if mgr.is_visible(handle) && !mgr.is_pending_close(handle) {
mgr.hover_leave(handle);
}
}
});
for (idx, item) in items.iter().enumerate() {
if item.is_separator() {
menu = menu.child(hr_with_bg(bg));
} else {
let item_label = item.get_label().to_string();
let item_shortcut = item.get_shortcut().map(|s| s.to_string());
let item_icon = item.get_icon().map(|s| s.to_string());
let item_disabled = item.is_disabled();
let item_on_click = item.get_on_click();
let has_submenu = item.has_submenu();
let submenu_items = item.get_submenu().cloned();
let parent_handle_for_click = parent_handle_state.clone();
let nested_submenu_for_hover = nested_submenu_handle.clone();
let nested_submenu_for_leave = nested_submenu_handle.clone();
let submenu_key = format!("{}_sub-{}", key, idx);
let item_text_color = if item_disabled {
text_tertiary
} else {
text_color
};
let shortcut_color = text_secondary;
let cursor = if item_disabled {
CursorStyle::NotAllowed
} else {
CursorStyle::Pointer
};
let mut left_side = div()
.w_fit()
.h_fit()
.flex_row()
.items_center()
.gap(padding / 4.0);
if let Some(ref icon_svg) = item_icon {
left_side = left_side.child(svg(icon_svg).size(16.0, 16.0).color(item_text_color));
}
left_side = left_side
.child(
text(&item_label)
.size(font_size)
.color(item_text_color)
.no_cursor()
.pointer_events_none(),
)
.pointer_events_none();
let right_side: Option<Div> = if let Some(ref shortcut) = item_shortcut {
Some(
div().child(
text(shortcut)
.size(font_size - 2.0)
.color(shortcut_color)
.no_cursor(),
),
)
} else if has_submenu {
let chevron_right = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>"#;
Some(
div()
.child(svg(chevron_right).size(12.0, 12.0).color(text_tertiary))
.pointer_events_none(),
)
} else {
None
};
let mut row_content = div()
.w_full()
.h_fit()
.flex_row()
.items_center()
.justify_between()
.child(left_side);
if let Some(right) = right_side {
row_content = row_content.child(right);
}
let mut row = row_content
.class("cn-menubar-item")
.w_full()
.h_fit()
.py(padding / 4.0)
.px(padding / 2.0)
.cursor(cursor)
.on_click(move |_| {
if !item_disabled && !has_submenu {
if let Some(ref cb) = item_on_click {
cb();
}
let mgr = get_overlay_manager();
mgr.close_all_of(blinc_layout::widgets::overlay::OverlayKind::Tooltip);
}
});
if has_submenu && !item_disabled {
let submenu_items_for_hover = submenu_items.clone();
let parent_handle_for_submenu = parent_handle_state.clone();
let submenu_key_for_hover = submenu_key.clone();
row = row.on_hover_enter(move |ctx| {
let mgr = get_overlay_manager();
if let Some(handle_id) = nested_submenu_for_hover.get() {
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
}
if let Some(ref items) = submenu_items_for_hover {
let x = ctx.bounds_x + ctx.bounds_width + 4.0;
let y = ctx.bounds_y;
let handle = show_menubar_submenu(
x,
y,
items,
160.0,
parent_handle_for_submenu.clone(),
nested_submenu_for_hover.clone(),
submenu_key_for_hover.clone(),
);
nested_submenu_for_hover.set(Some(handle.id()));
}
});
} else {
row = row.on_hover_enter(move |_| {
if let Some(handle_id) = nested_submenu_for_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
nested_submenu_for_leave.set(None);
}
});
}
menu = menu.child(row);
}
}
div().child(menu)
}
#[allow(clippy::too_many_arguments)]
fn build_menubar_dropdown_content(
items: &[ContextMenuItem],
width: f32,
overlay_handle_state: &State<Option<u64>>,
active_menu_state: &State<Option<usize>>,
key: &str,
bg: Color,
border: Color,
text_color: Color,
text_secondary: Color,
text_tertiary: Color,
radius: f32,
font_size: f32,
padding: f32,
) -> Div {
let menu_id = key;
let submenu_handle: State<Option<u64>> =
BlincContextState::get().use_state_keyed(&format!("{}_submenu", key), || None);
let mut menu = div()
.id(menu_id)
.flex_col()
.w(width)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg()
.overflow_clip()
.h_fit()
.py(1.0);
for (idx, item) in items.iter().enumerate() {
if item.is_separator() {
menu = menu.child(hr_with_bg(bg));
} else {
let item_label = item.get_label().to_string();
let item_shortcut = item.get_shortcut().map(|s| s.to_string());
let item_icon = item.get_icon().map(|s| s.to_string());
let item_disabled = item.is_disabled();
let item_on_click = item.get_on_click();
let has_submenu = item.has_submenu();
let submenu_items = item.get_submenu().cloned();
let handle_state_for_click = overlay_handle_state.clone();
let submenu_handle_for_hover = submenu_handle.clone();
let submenu_handle_for_leave = submenu_handle.clone();
let submenu_key = format!("{}_sub-{}", key, idx);
let item_text_color = if item_disabled {
text_tertiary
} else {
text_color
};
let shortcut_color = text_secondary;
let cursor = if item_disabled {
CursorStyle::NotAllowed
} else {
CursorStyle::Pointer
};
let mut left_side = div()
.w_fit()
.h_fit()
.flex_row()
.items_center()
.gap(padding / 4.0);
if let Some(ref icon_svg) = item_icon {
left_side = left_side.child(svg(icon_svg).size(16.0, 16.0).color(item_text_color));
}
left_side = left_side
.child(
text(&item_label)
.size(font_size)
.color(item_text_color)
.no_cursor()
.pointer_events_none(),
)
.pointer_events_none();
let right_side: Option<Div> = if let Some(ref shortcut) = item_shortcut {
Some(
div().child(
text(shortcut)
.size(font_size - 2.0)
.color(shortcut_color)
.no_cursor(),
),
)
} else if has_submenu {
let chevron_right = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>"#;
Some(
div()
.child(svg(chevron_right).size(12.0, 12.0).color(text_tertiary))
.pointer_events_none(),
)
} else {
None
};
let mut row_content = div()
.w_full()
.h_fit()
.flex_row()
.items_center()
.justify_between()
.child(left_side);
if let Some(right) = right_side {
row_content = row_content.child(right);
}
let mut row = row_content
.class("cn-menubar-item")
.w_full()
.h_fit()
.py(padding / 4.0)
.px(padding / 2.0)
.cursor(cursor)
.on_click(move |_| {
if !item_disabled && !has_submenu {
if let Some(ref cb) = item_on_click {
cb();
}
if let Some(handle_id) = handle_state_for_click.get() {
let mgr = get_overlay_manager();
mgr.close(OverlayHandle::from_raw(handle_id));
}
}
});
if has_submenu && !item_disabled {
let submenu_items_for_hover = submenu_items.clone();
let overlay_handle_for_submenu = overlay_handle_state.clone();
let submenu_key_for_hover = submenu_key.clone();
row = row.on_hover_enter(move |ctx| {
let mgr = get_overlay_manager();
if let Some(handle_id) = submenu_handle_for_hover.get() {
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
}
if let Some(ref items) = submenu_items_for_hover {
let x = ctx.bounds_x + ctx.bounds_width + 4.0;
let y = ctx.bounds_y;
let handle = show_menubar_submenu(
x,
y,
items,
160.0,
overlay_handle_for_submenu.clone(),
submenu_handle_for_hover.clone(),
submenu_key_for_hover.clone(),
);
submenu_handle_for_hover.set(Some(handle.id()));
}
});
} else {
row = row.on_hover_enter(move |_| {
if let Some(handle_id) = submenu_handle_for_leave.get() {
let mgr = get_overlay_manager();
let handle = OverlayHandle::from_raw(handle_id);
mgr.close_immediate(handle);
submenu_handle_for_leave.set(None);
}
});
}
menu = menu.child(row);
}
}
div().child(menu)
}
pub struct Menubar {
inner: Div,
}
impl std::fmt::Debug for Menubar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Menubar").finish()
}
}
impl ElementBuilder for MenubarBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().inner.element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.get_or_build().inner.layout_style()
}
fn event_handlers(&self) -> Option<&blinc_layout::event_handler::EventHandlers> {
ElementBuilder::event_handlers(&self.get_or_build().inner)
}
fn element_classes(&self) -> &[String] {
self.get_or_build().inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().inner.element_id()
}
}
#[track_caller]
pub fn menubar() -> MenubarBuilder {
MenubarBuilder::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_menubar_builder() {
let mb = menubar()
.menu("File", |m| {
m.item("New", || {}).separator().item("Exit", || {})
})
.menu("Edit", |m| {
m.item_with_shortcut("Undo", "Ctrl+Z", || {})
.item_with_shortcut("Redo", "Ctrl+Y", || {})
});
assert_eq!(mb.menus.len(), 2);
let file_str = String::from("File");
assert!(matches!(
&mb.menus[0].trigger,
MenubarTrigger::Label(file_str)
));
assert_eq!(mb.menus[0].items.len(), 3); let edit_str = String::from("Edit");
assert!(matches!(
&mb.menus[1].trigger,
MenubarTrigger::Label(edit_str)
));
assert_eq!(mb.menus[1].items.len(), 2);
}
#[test]
fn test_menu_with_submenu() {
let mb = menubar().menu("File", |m| {
m.item("New", || {}).submenu("Recent", |sub| {
sub.item("File 1", || {}).item("File 2", || {})
})
});
assert_eq!(mb.menus[0].items.len(), 2);
assert!(mb.menus[0].items[1].has_submenu());
}
}