kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
//! Segmented navigation with animated sliding highlight indicator.

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

use crate::animations::{durations, easings};
use crate::theme::use_theme;

#[derive(Clone)]
struct SegmentedNavItem {
    id: SharedString,
    label: SharedString,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SegmentedNavSize {
    Sm,
    #[default]
    Md,
    Lg,
}

impl SegmentedNavSize {
    fn height(&self) -> Pixels {
        match self {
            Self::Sm => px(32.0),
            Self::Md => px(40.0),
            Self::Lg => px(48.0),
        }
    }

    fn text_size(&self) -> Pixels {
        match self {
            Self::Sm => px(12.0),
            Self::Md => px(14.0),
            Self::Lg => px(16.0),
        }
    }

    fn padding_x(&self) -> Pixels {
        match self {
            Self::Sm => px(8.0),
            Self::Md => px(12.0),
            Self::Lg => px(16.0),
        }
    }
}

pub struct SegmentedNavState {
    active: SharedString,
    previous_active: Option<SharedString>,
    items: Vec<SegmentedNavItem>,
    animation_version: usize,
}

impl SegmentedNavState {
    pub fn new(active: impl Into<SharedString>) -> Self {
        Self {
            active: active.into(),
            previous_active: None,
            items: Vec::new(),
            animation_version: 0,
        }
    }

    pub fn set_active(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
        let new_id = id.into();
        if self.active != new_id {
            self.previous_active = Some(self.active.clone());
            self.active = new_id;
            self.animation_version = self.animation_version.wrapping_add(1);
            cx.notify();
        }
    }

    pub fn active(&self) -> &SharedString {
        &self.active
    }

    fn _active_index(&self) -> Option<usize> {
        self.items.iter().position(|item| item.id == self.active)
    }
}

#[derive(IntoElement)]
pub struct SegmentedNav {
    id: ElementId,
    state: Entity<SegmentedNavState>,
    items: Vec<SegmentedNavItem>,
    nav_size: SegmentedNavSize,
    on_change: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
    duration: Duration,
    style: StyleRefinement,
}

impl SegmentedNav {
    pub fn new(id: impl Into<ElementId>, state: Entity<SegmentedNavState>) -> Self {
        Self {
            id: id.into(),
            state,
            items: Vec::new(),
            nav_size: SegmentedNavSize::default(),
            on_change: None,
            duration: durations::NORMAL,
            style: StyleRefinement::default(),
        }
    }

    pub fn item(mut self, id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        self.items.push(SegmentedNavItem {
            id: id.into(),
            label: label.into(),
        });
        self
    }

    pub fn size(mut self, size: SegmentedNavSize) -> Self {
        self.nav_size = size;
        self
    }

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

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

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

impl RenderOnce for SegmentedNav {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = use_theme();
        let user_style = self.style;
        let state = self.state.read(cx);
        let active_id = state.active.clone();
        let item_count = self.items.len();
        let active_index = self.items.iter().position(|i| i.id == active_id);
        let animation_version = state.animation_version;
        let duration = self.duration;

        self.state.update(cx, |state, _| {
            state.items = self.items.clone();
        });

        let item_fraction = if item_count > 0 {
            1.0 / item_count as f32
        } else {
            1.0
        };

        div()
            .id(self.id)
            .flex()
            .items_center()
            .relative()
            .bg(theme.tokens.muted)
            .rounded(theme.tokens.radius_md)
            .p(px(4.0))
            .h(self.nav_size.height())
            .when(active_index.is_some(), |this| {
                let idx = active_index.unwrap();
                this.child(
                    div()
                        .id("segmented-indicator")
                        .absolute()
                        .top(px(4.0))
                        .bottom(px(4.0))
                        .rounded(theme.tokens.radius_sm)
                        .bg(theme.tokens.background)
                        .shadow(smallvec::smallvec![BoxShadow {
                            color: hsla(0.0, 0.0, 0.0, 0.08),
                            offset: point(px(0.0), px(1.0)),
                            blur_radius: px(3.0),
                            spread_radius: px(0.0),
                            inset: false,
                        }])
                        .with_animation(
                            ElementId::Name(format!("seg-slide-{}", animation_version).into()),
                            Animation::new(duration).with_easing(easings::ease_out_cubic),
                            move |el, delta| {
                                let frac = item_fraction;
                                let left_pct = idx as f32 * frac * 100.0;
                                let width_pct = frac * 100.0;
                                el.left(relative(
                                    left_pct / 100.0 * delta + left_pct / 100.0 * (1.0 - delta),
                                ))
                                .w(relative(width_pct / 100.0))
                            },
                        ),
                )
            })
            .children(self.items.iter().enumerate().map(|(idx, item)| {
                let item_id = item.id.clone();
                let is_active = item.id == active_id;
                let on_change = self.on_change.clone();
                let state = self.state.clone();
                let click_id = item_id.clone();

                div()
                    .id(ElementId::Name(format!("seg-item-{}", idx).into()))
                    .flex_1()
                    .flex()
                    .items_center()
                    .justify_center()
                    .h_full()
                    .px(self.nav_size.padding_x())
                    .text_size(self.nav_size.text_size())
                    .font_weight(if is_active {
                        FontWeight::MEDIUM
                    } else {
                        FontWeight::NORMAL
                    })
                    .text_color(if is_active {
                        theme.tokens.foreground
                    } else {
                        theme.tokens.muted_foreground
                    })
                    .cursor_pointer()
                    .on_mouse_down(MouseButton::Left, move |_, window, cx| {
                        state.update(cx, |state, cx| {
                            state.set_active(click_id.clone(), cx);
                        });
                        if let Some(handler) = on_change.as_ref() {
                            handler(item_id.clone(), window, cx);
                        }
                    })
                    .child(item.label.clone())
            }))
            .map(|this| {
                let mut el = this;
                el.style().refine(&user_style);
                el
            })
    }
}