kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
//! Context menu component for right-click menus.

use kael::{prelude::FluentBuilder as _, *};
use std::rc::Rc;
use std::time::Duration;

use crate::animations::easings;
use crate::theme::use_theme;

#[derive(Clone)]
pub struct ContextMenuItem {
    label: SharedString,
    icon: Option<SharedString>,
    disabled: bool,
    divider: bool,
    on_click: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
}

impl ContextMenuItem {
    pub fn new(_id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        Self {
            label: label.into(),
            icon: None,
            disabled: false,
            divider: false,
            on_click: None,
        }
    }

    pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
        self.icon = Some(icon.into());
        self
    }

    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    pub fn divider(mut self, divider: bool) -> Self {
        self.divider = divider;
        self
    }

    pub fn on_click<F>(mut self, handler: F) -> Self
    where
        F: Fn(&mut Window, &mut App) + 'static,
    {
        self.on_click = Some(Rc::new(handler));
        self
    }

    pub fn separator() -> Self {
        Self {
            label: "".into(),
            icon: None,
            disabled: true,
            divider: true,
            on_click: None,
        }
    }
}

#[derive(IntoElement)]
pub struct ContextMenu {
    position: Point<Pixels>,
    items: Vec<ContextMenuItem>,
    on_close: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
    dismissing: bool,
    style: StyleRefinement,
}

impl ContextMenu {
    pub fn new(position: Point<Pixels>) -> Self {
        Self {
            position,
            items: Vec::new(),
            on_close: None,
            dismissing: false,
            style: StyleRefinement::default(),
        }
    }

    pub fn dismissing(mut self, dismissing: bool) -> Self {
        self.dismissing = dismissing;
        self
    }

    pub fn item(mut self, item: ContextMenuItem) -> Self {
        self.items.push(item);
        self
    }

    pub fn items(mut self, items: Vec<ContextMenuItem>) -> Self {
        self.items.extend(items);
        self
    }

    pub fn on_close<F>(mut self, handler: F) -> Self
    where
        F: Fn(&mut Window, &mut App) + 'static,
    {
        self.on_close = Some(Rc::new(handler));
        self
    }
}

impl Styled for ContextMenu {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for ContextMenu {
    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
        let theme = use_theme();
        let position = self.position;
        let on_close_handler = self.on_close.clone();
        let user_style = self.style;
        let dismissing = self.dismissing;

        div()
            .absolute()
            .inset_0()
            .when(on_close_handler.is_some(), |this: Div| {
                let handler = on_close_handler.clone().unwrap();
                let handler2 = handler.clone();
                this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
                    handler(window, cx);
                })
                .on_mouse_down(MouseButton::Right, move |_, window, cx| {
                    handler2(window, cx);
                })
            })
            .child(
                div()
                    .absolute()
                    .occlude()
                    .left(position.x)
                    .top(position.y)
                    .min_w(px(200.0))
                    .bg(theme.tokens.popover)
                    .border_1()
                    .border_color(theme.tokens.border)
                    .rounded(theme.tokens.radius_md)
                    .shadow(smallvec::smallvec![BoxShadow {
                        color: hsla(0.0, 0.0, 0.0, 0.1),
                        offset: point(px(0.0), px(2.0)),
                        blur_radius: px(8.0),
                        spread_radius: px(0.0),
                        inset: false,
                    }])
                    .p(px(4.0))
                    .map(|this| {
                        let mut div = this;
                        div.style().refine(&user_style);
                        div
                    })
                    .on_mouse_down(MouseButton::Left, |_, _, _| {})
                    .children(self.items.into_iter().map(|item| {
                        if item.label.is_empty() && item.divider {
                            return div()
                                .h(px(1.0))
                                .my(px(4.0))
                                .bg(theme.tokens.border)
                                .into_any_element();
                        }

                        let on_close = self.on_close.clone();
                        let handler = item.on_click.clone();
                        let disabled = item.disabled;

                        div()
                            .flex()
                            .items_center()
                            .gap(px(8.0))
                            .px(px(8.0))
                            .py(px(6.0))
                            .rounded(theme.tokens.radius_sm)
                            .text_size(px(14.0))
                            .cursor(if disabled {
                                CursorStyle::Arrow
                            } else {
                                CursorStyle::PointingHand
                            })
                            .when(disabled, |this: Div| {
                                this.text_color(theme.tokens.muted_foreground).opacity(0.5)
                            })
                            .when(!disabled, |this: Div| {
                                this.text_color(theme.tokens.popover_foreground)
                                    .hover(|style| style.bg(theme.tokens.accent.opacity(0.1)))
                            })
                            .when(!disabled && handler.is_some(), |this: Div| {
                                let handler = handler.unwrap();
                                let on_close = on_close.clone();
                                this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
                                    handler(window, cx);
                                    if let Some(close_handler) = &on_close {
                                        close_handler(window, cx);
                                    }
                                })
                            })
                            .when_some(item.icon, |this: Div, _icon| this)
                            .child(item.label)
                            .into_any_element()
                    }))
                    .with_animation(
                        if self.dismissing {
                            "ctx-menu-exit"
                        } else {
                            "ctx-menu-enter"
                        },
                        Animation::new(Duration::from_millis(if self.dismissing {
                            100
                        } else {
                            120
                        }))
                        .with_easing(if self.dismissing {
                            easings::ease_in_cubic as fn(f32) -> f32
                        } else {
                            easings::ease_out_cubic as fn(f32) -> f32
                        }),
                        move |el, delta| {
                            if dismissing {
                                el.opacity(1.0 - delta).mt(px(4.0 * delta))
                            } else {
                                el.opacity(delta).mt(px(4.0 * (1.0 - delta)))
                            }
                        },
                    ),
            )
    }
}