use std::cell::OnceCell;
use std::sync::Arc;
use blinc_animation::{AnimationPreset, MultiKeyframeAnimation};
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::prelude::*;
use blinc_layout::stateful::{stateful_with_key, ButtonState, NoState};
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_theme::{ColorScheme, ColorToken, RadiusToken, ThemeState};
use blinc_layout::selector::query_motion;
use blinc_layout::stateful::request_redraw;
use blinc_layout::InstanceKey;
#[derive(Clone, Debug, Default)]
struct TabTransitionState {
current_tab: String,
exiting_tab: Option<String>,
}
fn tab_transitions_store() -> &'static blinc_core::Store<TabTransitionState> {
blinc_core::create_store::<TabTransitionState>("tab-transitions")
}
fn update_tab_transition(tabs_id: &str, new_tab: &str, motion_base_key: &str) {
tab_transitions_store().update(tabs_id, |state| {
if state.current_tab != new_tab && !state.current_tab.is_empty() {
let old_tab = state.current_tab.clone();
let exit_motion_key = format!("motion:{}:{}:child:0", motion_base_key, old_tab);
query_motion(&exit_motion_key).exit();
state.exiting_tab = Some(old_tab);
}
state.current_tab = new_tab.to_string();
});
}
fn check_and_clear_exiting_tab(tabs_id: &str, motion_base_key: &str) -> Option<String> {
tab_transitions_store().update_with(tabs_id, |state| {
if let Some(ref exiting) = state.exiting_tab {
let exit_motion_key = format!("motion:{}:{}:child:0", motion_base_key, exiting);
let motion = query_motion(&exit_motion_key);
if !motion.is_animating() {
state.exiting_tab = None;
return None;
}
request_redraw();
return Some(exiting.clone());
}
None
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabsSize {
Small,
#[default]
Medium,
Large,
}
impl TabsSize {
fn height(&self) -> f32 {
match self {
TabsSize::Small => 32.0,
TabsSize::Medium => 40.0,
TabsSize::Large => 48.0,
}
}
fn font_size(&self) -> f32 {
match self {
TabsSize::Small => 13.0,
TabsSize::Medium => 14.0,
TabsSize::Large => 16.0,
}
}
fn padding_x(&self) -> f32 {
match self {
TabsSize::Small => 12.0,
TabsSize::Medium => 16.0,
TabsSize::Large => 20.0,
}
}
fn icon_size(&self) -> f32 {
match self {
TabsSize::Small => 14.0,
TabsSize::Medium => 16.0,
TabsSize::Large => 18.0,
}
}
fn badge_font_size(&self) -> f32 {
match self {
TabsSize::Small => 10.0,
TabsSize::Medium => 11.0,
TabsSize::Large => 12.0,
}
}
}
#[derive(Clone)]
pub struct TabMenuItem {
value: String,
label: Option<String>,
icon: Option<String>,
badge: Option<String>,
disabled: bool,
custom_content: Option<Arc<dyn Fn(bool) -> Div + Send + Sync>>,
}
impl std::fmt::Debug for TabMenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabMenuItem")
.field("value", &self.value)
.field("label", &self.label)
.field("icon", &self.icon.is_some())
.field("badge", &self.badge)
.field("disabled", &self.disabled)
.field("custom_content", &self.custom_content.is_some())
.finish()
}
}
impl TabMenuItem {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
label: None,
icon: None,
badge: None,
disabled: false,
custom_content: None,
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, svg: impl Into<String>) -> Self {
self.icon = Some(svg.into());
self
}
pub fn badge(mut self, badge: impl Into<String>) -> Self {
self.badge = Some(badge.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn content<F>(mut self, builder: F) -> Self
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
self.custom_content = Some(Arc::new(builder));
self
}
pub fn value(&self) -> &str {
&self.value
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
pub fn tab_item(value: impl Into<String>) -> TabMenuItem {
TabMenuItem::new(value)
}
pub type TabContentFn = Arc<dyn Fn() -> Div + Send + Sync>;
#[derive(Clone)]
struct TabItem {
menu_item: TabMenuItem,
content: TabContentFn,
}
impl std::fmt::Debug for TabItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabItem")
.field("menu_item", &self.menu_item)
.finish()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabsTransition {
None,
#[default]
Fade,
SlideLeft,
SlideRight,
SlideUp,
SlideDown,
}
impl TabsTransition {
fn enter_animation(&self) -> Option<MultiKeyframeAnimation> {
match self {
TabsTransition::None => None,
TabsTransition::Fade => Some(AnimationPreset::fade_in(250)),
TabsTransition::SlideLeft => Some(AnimationPreset::slide_in_left(250, 20.0)),
TabsTransition::SlideRight => Some(AnimationPreset::slide_in_right(250, 50.0)),
TabsTransition::SlideUp => Some(AnimationPreset::slide_in_top(250, 20.0)),
TabsTransition::SlideDown => Some(AnimationPreset::slide_in_bottom(250, 20.0)),
}
}
fn exit_animation(&self) -> Option<MultiKeyframeAnimation> {
match self {
TabsTransition::None => None,
TabsTransition::Fade => Some(AnimationPreset::fade_out(200)),
TabsTransition::SlideLeft => Some(AnimationPreset::slide_out_left(200, 20.0)),
TabsTransition::SlideRight => Some(AnimationPreset::slide_out_right(200, 25.0)),
TabsTransition::SlideUp => Some(AnimationPreset::slide_out_top(200, 20.0)),
TabsTransition::SlideDown => Some(AnimationPreset::slide_out_bottom(200, 20.0)),
}
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct TabsConfig {
state: State<String>,
tabs: Vec<TabItem>,
size: TabsSize,
default_value: Option<String>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
transition: TabsTransition,
}
impl std::fmt::Debug for TabsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabsConfig")
.field("tabs", &self.tabs)
.field("size", &self.size)
.field("default_value", &self.default_value)
.finish()
}
}
pub struct Tabs {
inner: Div,
}
impl std::fmt::Debug for Tabs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Tabs").finish()
}
}
pub struct TabsBuilder {
key: InstanceKey,
config: TabsConfig,
classes: Vec<String>,
user_id: Option<String>,
built: OnceCell<Tabs>,
}
impl std::fmt::Debug for TabsBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabsBuilder")
.field("config", &self.config)
.finish()
}
}
impl TabsBuilder {
#[track_caller]
pub fn new(state: &State<String>) -> Self {
Self {
key: InstanceKey::new("tabs"),
config: TabsConfig {
state: state.clone(),
tabs: Vec::new(),
size: TabsSize::default(),
default_value: None,
on_change: None,
transition: TabsTransition::default(),
},
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn with_key(key: impl Into<String>, state: &State<String>) -> Self {
Self {
key: InstanceKey::explicit(key),
config: TabsConfig {
state: state.clone(),
tabs: Vec::new(),
size: TabsSize::default(),
default_value: None,
on_change: None,
transition: TabsTransition::default(),
},
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn tab<F>(mut self, value: impl Into<String>, label: impl Into<String>, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
let value_str = value.into();
let label_str = label.into();
self.config.tabs.push(TabItem {
menu_item: TabMenuItem::new(value_str).label(label_str),
content: Arc::new(content),
});
self
}
pub fn tab_item<F>(mut self, item: TabMenuItem, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.config.tabs.push(TabItem {
menu_item: item,
content: Arc::new(content),
});
self
}
pub fn tab_disabled<F>(
mut self,
value: impl Into<String>,
label: impl Into<String>,
content: F,
) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
let value_str = value.into();
let label_str = label.into();
self.config.tabs.push(TabItem {
menu_item: TabMenuItem::new(value_str).label(label_str).disabled(),
content: Arc::new(content),
});
self
}
pub fn size(mut self, size: TabsSize) -> Self {
self.config.size = size;
self
}
pub fn default_value(mut self, value: impl Into<String>) -> Self {
self.config.default_value = Some(value.into());
self
}
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(&str) + Send + Sync + 'static,
{
self.config.on_change = Some(Arc::new(callback));
self
}
pub fn transition(mut self, transition: TabsTransition) -> Self {
self.config.transition = transition;
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) -> &Tabs {
self.built.get_or_init(|| self.build_component())
}
fn build_component(&self) -> Tabs {
let theme = ThemeState::get();
let config = &self.config;
let current_value = config.state.get();
if current_value.is_empty() {
if let Some(ref default) = config.default_value {
config.state.set(default.clone());
} else if let Some(first_tab) = config.tabs.first() {
let first_enabled = config
.tabs
.iter()
.find(|t| !t.menu_item.is_disabled())
.map(|t| t.menu_item.value().to_string())
.unwrap_or_else(|| first_tab.menu_item.value().to_string());
config.state.set(first_enabled);
}
}
let tab_list_bg = theme.color(ColorToken::SurfaceOverlay);
let radius = theme.radius(RadiusToken::Md);
let content_margin = theme.spacing().space_1;
let size = config.size;
let border = if matches!(theme.scheme(), ColorScheme::Dark) {
theme.color(ColorToken::Surface)
} else {
Color::TRANSPARENT
};
let tabs_for_buttons = config.tabs.clone();
let state_for_buttons = config.state.clone();
let on_change = config.on_change.clone();
let transition = config.transition;
let trigger_key = self.key.derive("tab_triggers");
let motion_base_key_str = self.key.derive("motion");
let button_area_key = self.key.derive("button_area");
let tab_button_area = stateful_with_key::<NoState>(&button_area_key)
.deps([config.state.signal_id()])
.on_state(move |_ctx| {
let active_value = state_for_buttons.get();
let mut buttons = div()
.class("cn-tabs-list")
.h(size.height())
.w_full()
.bg(tab_list_bg)
.rounded_md()
.padding(Length::Px(6.0))
.flex_row()
.items_center()
.border(1.0, border)
.gap(4.0);
for tab in tabs_for_buttons.iter() {
let is_active = tab.menu_item.value() == active_value;
let value = tab.menu_item.value();
let tab_motion_key = if transition != TabsTransition::None {
Some(format!("{}:{}", motion_base_key_str, value))
} else {
None
};
let tab_trigger = build_tab_trigger(
&trigger_key,
&tab.menu_item,
is_active,
size,
state_for_buttons.clone(),
on_change.clone(),
tab_motion_key,
);
buttons = buttons.child(tab_trigger);
}
buttons
});
let tabs_for_content = config.tabs.clone();
let state_for_content = config.state.clone();
let transition = config.transition;
let motion_base_key = self.key.derive("motion");
let tabs_id = self.key.get().to_string();
let content_area_key = self.key.derive("content_area");
let tab_content_area = stateful_with_key::<NoState>(&content_area_key)
.deps([config.state.signal_id()])
.on_state(move |ctx| {
let active_value = state_for_content.get();
update_tab_transition(&tabs_id, &active_value, &motion_base_key);
let exiting_tab = check_and_clear_exiting_tab(&tabs_id, &motion_base_key);
let build_tab_content = |tab_value: &str, is_exiting: bool| -> Option<Div> {
tabs_for_content
.iter()
.find(|t| t.menu_item.value() == tab_value)
.map(|tab| {
let content = (tab.content)();
if transition != TabsTransition::None {
let tab_motion_key = format!("{}:{}", motion_base_key, tab_value);
let mut m = motion_derived(&tab_motion_key);
if !is_exiting {
if let Some(enter) = transition.enter_animation() {
m = m.enter_animation(enter);
}
}
if let Some(exit) = transition.exit_animation() {
m = m.exit_animation(exit);
}
div()
.w_full()
.flex_grow()
.absolute()
.left(0.0)
.top(0.0)
.right(0.0)
.bottom(0.0)
.child(m.child(content))
} else {
div().w_full().flex_grow().child(content)
}
})
};
if let Some(ref exiting) = exiting_tab {
use blinc_layout::stack::stack;
let mut content_stack = stack().w_full().flex_grow();
if let Some(exiting_content) = build_tab_content(exiting, true) {
content_stack = content_stack.child(exiting_content);
}
if let Some(current_content) = build_tab_content(&active_value, false) {
content_stack = content_stack.child(current_content);
}
div()
.w_full()
.mt(content_margin)
.flex_grow()
.relative()
.child(content_stack)
} else {
if let Some(current_content) = build_tab_content(&active_value, false) {
div()
.w_full()
.mt(content_margin)
.flex_grow()
.child(current_content)
} else {
div().w_full().flex_grow()
}
}
});
let mut container = div()
.w_full()
.flex_grow()
.flex_col()
.child(tab_button_area)
.child(tab_content_area);
for c in &self.classes {
container = container.class(c);
}
if let Some(ref id) = self.user_id {
container = container.id(id);
}
Tabs { inner: container }
}
}
#[allow(clippy::type_complexity)]
fn build_tab_trigger(
trigger_key: &str,
menu_item: &TabMenuItem,
is_active: bool,
size: TabsSize,
tab_state: State<String>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
motion_key: Option<String>,
) -> impl ElementBuilder {
let theme = ThemeState::get();
let text_primary = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let surface = theme.color(ColorToken::SurfaceElevated);
let radius = theme.radius(RadiusToken::Md);
let value = menu_item.value.clone();
let disabled = menu_item.disabled;
let inner_height = size.height() - 16.0;
let icon_svg = menu_item.icon.clone();
let label_text = menu_item.label.clone();
let badge_text = menu_item.badge.clone();
let trigger_state_key = format!("{}:{}", trigger_key, value);
let mut trigger = stateful_with_key::<ButtonState>(&trigger_state_key).on_state(move |ctx| {
let state = ctx.state();
let theme = ThemeState::get();
let is_hovered = matches!(state, ButtonState::Hovered | ButtonState::Pressed);
let text_color = if disabled {
text_secondary.with_alpha(0.5)
} else if is_active {
text_primary
} else if is_hovered {
text_primary.with_alpha(0.8)
} else {
text_secondary
};
let bg = if is_active && !disabled {
surface
} else if is_hovered && !disabled {
surface.with_alpha(0.5)
} else {
Color::TRANSPARENT
};
let mut content = div().flex_row().items_center().gap(theme.spacing().space_2);
if let Some(ref icon) = icon_svg {
content = content.child(
svg(icon)
.size(size.icon_size(), size.icon_size())
.color(text_color),
);
}
if let Some(ref label) = label_text {
content = content.child(
text(label)
.size(size.font_size())
.color(text_color)
.weight(if is_active {
FontWeight::Medium
} else {
FontWeight::Normal
})
.no_cursor(),
);
}
if let Some(ref badge) = badge_text {
let primary = theme.color(ColorToken::Primary);
content = content.child(
div()
.px(theme.spacing().space_1_5)
.py(1.0)
.bg(primary)
.rounded(theme.radius(RadiusToken::Full))
.child(
text(badge)
.size(size.badge_font_size())
.color(theme.color(ColorToken::PrimaryActive))
.medium()
.no_cursor(),
),
);
}
let trigger_size_class = match size {
TabsSize::Small => "cn-tabs-trigger--sm",
TabsSize::Medium => "cn-tabs-trigger--md",
TabsSize::Large => "cn-tabs-trigger--lg",
};
let mut trigger_div = div()
.class("cn-tabs-trigger")
.class(trigger_size_class)
.h(inner_height)
.padding_x(Length::Px(size.padding_x()))
.padding_y(Length::Px(
size.padding_x() / if size != TabsSize::Small { 2.0 } else { 1.0 },
))
.flex_row()
.items_center()
.justify_center()
.when(size == TabsSize::Small, |d| d.rounded_sm())
.when(size != TabsSize::Small, |d| d.rounded_md())
.bg(bg)
.cursor(if disabled {
CursorStyle::Default
} else {
CursorStyle::Pointer
})
.child(content);
if is_active && !disabled {
trigger_div = trigger_div.class("cn-tabs-trigger--active").shadow_sm();
}
if disabled {
trigger_div = trigger_div.class("cn-tabs-trigger--disabled");
}
trigger_div
});
if !disabled && !is_active {
let value_for_click = value.clone();
trigger = trigger.on_click(move |_| {
if let Some(ref mk) = motion_key {
let full_motion_key = format!("motion:{}:child:0", mk);
query_motion(&full_motion_key).start();
}
tab_state.set(value_for_click.clone());
if let Some(ref cb) = on_change {
cb(&value_for_click);
}
});
}
trigger
}
impl ElementBuilder for TabsBuilder {
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 element_classes(&self) -> &[String] {
self.get_or_build().inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().inner.element_id()
}
}
impl std::ops::Deref for TabsBuilder {
type Target = Div;
fn deref(&self) -> &Self::Target {
&self.get_or_build().inner
}
}
#[track_caller]
pub fn tabs(state: &State<String>) -> TabsBuilder {
TabsBuilder::new(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tabs_size() {
assert_eq!(TabsSize::Small.height(), 32.0);
assert_eq!(TabsSize::Medium.height(), 40.0);
assert_eq!(TabsSize::Large.height(), 48.0);
}
}