use std::sync::Arc;
use blinc_core::context_state::BlincContextState;
use blinc_core::{Color, Signal, State};
use blinc_layout::click_outside;
use blinc_layout::element::CursorStyle;
use blinc_layout::overlay_state::overlay_stack;
use blinc_layout::prelude::*;
use blinc_layout::widgets::hr::hr;
use blinc_layout::widgets::overlay_stack::{OverlayBuilder, OverlayHandle};
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
#[derive(Clone)]
pub struct ContextMenuItem {
label: String,
shortcut: Option<String>,
icon: Option<String>,
on_click: Option<Arc<dyn Fn() + Send + Sync>>,
disabled: bool,
is_separator: bool,
submenu: Option<Vec<ContextMenuItem>>,
}
impl std::fmt::Debug for ContextMenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ContextMenuItem")
.field("label", &self.label)
.field("shortcut", &self.shortcut)
.field("icon", &self.icon.is_some())
.field("disabled", &self.disabled)
.field("is_separator", &self.is_separator)
.field("submenu", &self.submenu.as_ref().map(|s| s.len()))
.finish()
}
}
impl ContextMenuItem {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
shortcut: None,
icon: None,
on_click: None,
disabled: false,
is_separator: false,
submenu: None,
}
}
pub fn separator() -> Self {
Self {
label: String::new(),
shortcut: None,
icon: None,
on_click: None,
disabled: false,
is_separator: true,
submenu: None,
}
}
pub fn on_click<F>(mut self, f: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.on_click = Some(Arc::new(f));
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn icon(mut self, svg: impl Into<String>) -> Self {
self.icon = Some(svg.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn submenu(mut self, items: Vec<ContextMenuItem>) -> Self {
self.submenu = Some(items);
self
}
pub fn get_label(&self) -> &str {
&self.label
}
pub fn get_shortcut(&self) -> Option<&str> {
self.shortcut.as_deref()
}
pub fn get_icon(&self) -> Option<&str> {
self.icon.as_deref()
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn is_separator(&self) -> bool {
self.is_separator
}
pub fn has_submenu(&self) -> bool {
self.submenu.is_some()
}
pub fn get_submenu(&self) -> Option<&Vec<ContextMenuItem>> {
self.submenu.as_ref()
}
pub fn get_on_click(&self) -> Option<Arc<dyn Fn() + Send + Sync>> {
self.on_click.clone()
}
}
pub struct ContextMenuBuilder {
x: f32,
y: f32,
items: Vec<ContextMenuItem>,
min_width: f32,
classes: Vec<std::sync::Arc<str>>,
user_id: Option<String>,
}
impl ContextMenuBuilder {
pub fn new() -> Self {
Self {
x: 0.0,
y: 0.0,
items: Vec::new(),
min_width: 180.0,
classes: Vec::new(),
user_id: None,
}
}
pub fn at(mut self, x: f32, y: f32) -> Self {
self.x = x;
self.y = y;
self
}
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());
self.items
.push(ContextMenuItem::new(label).submenu(sub.items));
self
}
pub fn add_item(mut self, item: ContextMenuItem) -> Self {
self.items.push(item);
self
}
pub fn class(mut self, name: impl AsRef<str>) -> Self {
self.classes.push(blinc_core::intern::intern(name.as_ref()));
self
}
pub fn id(mut self, id: &str) -> Self {
self.user_id = Some(id.to_string());
self
}
pub fn min_width(mut self, width: f32) -> Self {
self.min_width = width;
self
}
pub fn show(self) -> OverlayHandle {
let theme = ThemeState::get();
let radius = theme.radius(RadiusToken::Md);
let font_size = 14.0;
let padding = 12.0;
let items = self.items;
let width = self.min_width;
let x = self.x;
let y = self.y;
let classes = self.classes;
let user_id = self.user_id;
let next_handle_id = overlay_stack()
.lock()
.ok()
.map(|s| s.peek_next_handle_id())
.unwrap_or(0);
let menu_handle = OverlayHandle::from_raw(next_handle_id);
let menu_id = format!("cn-context-menu-{}", next_handle_id);
let click_outside_key = format!("ctxmenu:{}", next_handle_id);
let id_chain: Signal<Vec<String>> = BlincContextState::get()
.use_signal_keyed(&format!("ctxmenu_chain_{}", next_handle_id), || {
Vec::<String>::new()
});
id_chain.set(vec![menu_id.clone()]);
let click_outside_key_for_close = click_outside_key.clone();
let id_chain_for_content = id_chain;
let menu_id_for_content = menu_id.clone();
let click_outside_key_for_content = click_outside_key.clone();
let est_item_h = font_size + padding;
let est_height = (items.len() as f32) * est_item_h + padding;
let handle = OverlayBuilder::context_menu()
.at(x, y)
.size(width, est_height)
.on_close(move |_reason| {
click_outside::unregister_click_outside(&click_outside_key_for_close);
})
.content(move || {
let mut content = build_menu_content(
&items,
width,
menu_handle,
&menu_id_for_content,
&click_outside_key_for_content,
&id_chain_for_content,
radius,
font_size,
padding,
);
for c in &classes {
content = content.class(c);
}
if let Some(ref id) = user_id {
content = content.id(id);
}
content
})
.show();
debug_assert_eq!(
handle.raw(),
next_handle_id,
"peek_next_handle_id was stale — concurrent push?"
);
click_outside::register_click_outside(&click_outside_key, &menu_id, move || {
menu_handle.close();
});
handle
}
}
impl Default for ContextMenuBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct SubmenuBuilder {
items: Vec<ContextMenuItem>,
}
impl SubmenuBuilder {
fn new() -> Self {
Self { 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 items(self) -> Vec<ContextMenuItem> {
self.items
}
}
impl SubmenuBuilder {
pub fn new_public() -> Self {
Self::new()
}
}
impl Default for SubmenuBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_submenu(
x: f32,
y: f32,
items: &[ContextMenuItem],
min_width: f32,
root_handle: OverlayHandle,
root_click_outside_key: String,
id_chain: Signal<Vec<String>>,
parent_submenu_state: Signal<Option<u64>>,
) -> OverlayHandle {
let theme = ThemeState::get();
let radius = theme.radius(RadiusToken::Md);
let font_size = 14.0;
let padding = 12.0;
let items = items.to_vec();
let next_handle_id = overlay_stack()
.lock()
.ok()
.map(|s| s.peek_next_handle_id())
.unwrap_or(0);
let submenu_handle = OverlayHandle::from_raw(next_handle_id);
let submenu_id = format!("cn-context-menu-{}", next_handle_id);
let mut chain = id_chain.get();
if !chain.contains(&submenu_id) {
chain.push(submenu_id.clone());
id_chain.set(chain);
click_outside::update_click_outside_ids(&root_click_outside_key, &id_chain.get());
}
let id_chain_for_close = id_chain;
let root_click_outside_key_for_close = root_click_outside_key.clone();
let submenu_id_for_close = submenu_id.clone();
let parent_submenu_state_for_close = parent_submenu_state;
let id_chain_for_content = id_chain;
let root_click_outside_key_for_content = root_click_outside_key.clone();
let submenu_id_for_content = submenu_id.clone();
let handle = OverlayBuilder::context_menu()
.at(x, y)
.on_close(move |_reason| {
let mut chain = id_chain_for_close.get();
chain.retain(|id| id != &submenu_id_for_close);
id_chain_for_close.set(chain);
click_outside::update_click_outside_ids(
&root_click_outside_key_for_close,
&id_chain_for_close.get(),
);
if parent_submenu_state_for_close.get() == Some(submenu_handle.raw()) {
parent_submenu_state_for_close.set(None);
}
})
.content(move || {
let mut content = build_menu_content(
&items,
min_width,
root_handle,
&submenu_id_for_content,
&root_click_outside_key_for_content,
&id_chain_for_content,
radius,
font_size,
padding,
);
content = content.id(&submenu_id_for_content);
content
})
.show();
debug_assert_eq!(
handle.raw(),
next_handle_id,
"peek_next_handle_id was stale — concurrent push?"
);
handle
}
#[allow(clippy::too_many_arguments)]
fn build_menu_content(
items: &[ContextMenuItem],
width: f32,
root_handle: OverlayHandle,
self_id: &str,
root_click_outside_key: &str,
id_chain: &Signal<Vec<String>>,
radius: f32,
font_size: f32,
padding: f32,
) -> Div {
let child_submenu_state: Signal<Option<u64>> = BlincContextState::get()
.use_signal_keyed(&format!("ctxmenu_child_sub_{}", self_id), || None);
let mut menu = super::menu::panel(radius)
.class("cn-context-menu")
.id(self_id)
.min_w(width)
.w_fit();
for (idx, item) in items.iter().enumerate() {
if item.is_separator {
menu = menu.child(super::menu::separator());
} else {
let item_label = item.label.clone();
let item_shortcut = item.shortcut.clone();
let item_icon = item.icon.clone();
let item_disabled = item.disabled;
let item_on_click = item.on_click.clone();
let has_submenu = item.submenu.is_some();
let submenu_items = item.submenu.clone();
let child_submenu_for_hover_open = child_submenu_state;
let child_submenu_for_hover_close = child_submenu_state;
let id_chain_for_open = *id_chain;
let root_click_outside_key_for_open = root_click_outside_key.to_string();
let (left_side, right_side) = super::menu::item_parts(
item_icon.as_deref(),
&item_label,
item_shortcut.as_deref(),
has_submenu,
font_size,
padding / 4.0,
false,
);
let mut row_content = div()
.class("cn-menu-item")
.class("cn-context-menu-item")
.attr("role", "menuitem")
.attr("tabindex", "-1")
.w_full()
.h_fit()
.py(1.5)
.px(3.0);
if has_submenu {
row_content = row_content.class("cn-context-menu-item--has-submenu");
}
row_content = row_content
.attr_when("disabled", "", item_disabled)
.flex_row()
.items_center()
.gap(padding)
.justify_between()
.child(left_side);
if let Some(right) = right_side {
row_content = row_content.child(right);
}
let mut row = row_content.on_click(move |_| {
if !item_disabled && !has_submenu {
root_handle.close();
if let Some(ref cb) = item_on_click {
cb();
}
}
});
if has_submenu && !item_disabled {
let submenu_items_for_hover = submenu_items.clone();
let id_chain_for_hover = id_chain_for_open;
let root_key_for_hover = root_click_outside_key_for_open.clone();
let open_submenu =
std::rc::Rc::new(move |ctx: &blinc_layout::event_handler::EventContext| {
if let Some(handle_id) = child_submenu_for_hover_open.get() {
OverlayHandle::from_raw(handle_id).close();
}
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 = spawn_submenu(
x,
y,
items,
160.0,
root_handle,
root_key_for_hover.clone(),
id_chain_for_hover,
child_submenu_for_hover_open,
);
child_submenu_for_hover_open.set(Some(handle.raw()));
}
});
let click_submenu = open_submenu.clone();
row = row
.attr("aria-haspopup", "menu")
.on_click(move |ctx| click_submenu(ctx))
.on_hover_enter(move |ctx| open_submenu(ctx));
} else {
row = row.on_hover_enter(move |_| {
if let Some(handle_id) = child_submenu_for_hover_close.get() {
OverlayHandle::from_raw(handle_id).close();
}
});
}
menu = menu.child(row);
}
}
menu
}
pub fn context_menu() -> ContextMenuBuilder {
ContextMenuBuilder::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_menu_item_creation() {
let item = ContextMenuItem::new("Test");
assert_eq!(item.label, "Test");
assert!(!item.disabled);
assert!(!item.is_separator);
}
#[test]
fn test_menu_item_with_shortcut() {
let item = ContextMenuItem::new("Copy").shortcut("Ctrl+C");
assert_eq!(item.shortcut, Some("Ctrl+C".to_string()));
}
#[test]
fn test_separator() {
let sep = ContextMenuItem::separator();
assert!(sep.is_separator);
}
#[test]
fn test_disabled_item() {
let item = ContextMenuItem::new("Disabled").disabled();
assert!(item.disabled);
}
#[test]
fn test_builder_items() {
let menu = ContextMenuBuilder::new()
.item("Item 1", || {})
.separator()
.item("Item 2", || {});
assert_eq!(menu.items.len(), 3);
assert!(!menu.items[0].is_separator);
assert!(menu.items[1].is_separator);
assert!(!menu.items[2].is_separator);
}
#[test]
fn test_builder_position() {
let menu = ContextMenuBuilder::new().at(100.0, 200.0);
assert_eq!(menu.x, 100.0);
assert_eq!(menu.y, 200.0);
}
}