use std::panic::Location;
use crate::anim::Timing;
use crate::event::{UiEvent, UiEventKind};
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TabsAction<'a> {
Select(&'a str),
}
pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<TabsAction<'a>> {
if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
return None;
}
let routed = event.route()?;
let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
let value = rest.strip_prefix("tab:")?;
Some(TabsAction::Select(value))
}
pub fn apply_event<V>(
value: &mut V,
event: &UiEvent,
key: &str,
parse: impl FnOnce(&str) -> Option<V>,
) -> bool {
let Some(TabsAction::Select(raw)) = classify_event(event, key) else {
return false;
};
if let Some(v) = parse(raw) {
*value = v;
}
true
}
pub fn tab_option_key(key: &str, value: &impl std::fmt::Display) -> String {
format!("{key}:tab:{value}")
}
#[track_caller]
pub fn tab_trigger(
list_key: &str,
value: impl std::fmt::Display,
label: impl Into<String>,
selected: bool,
) -> El {
let routed_key = tab_option_key(list_key, &value);
let base = El::new(Kind::Custom("tab_trigger"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::TabTrigger)
.focusable()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.key(routed_key)
.text(label)
.text_align(TextAlign::Center)
.text_role(TextRole::Label)
.default_radius(tokens::RADIUS_SM)
.width(Size::Fill(1.0))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0));
let styled = if selected {
base.current()
} else {
base.ghost()
};
styled.animate(Timing::SPRING_QUICK)
}
#[track_caller]
pub fn tab_trigger_content<I, E>(
list_key: &str,
value: impl std::fmt::Display,
children: I,
selected: bool,
) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
let routed_key = tab_option_key(list_key, &value);
let base = El::new(Kind::Custom("tab_trigger"))
.at_loc(Location::caller())
.style_profile(StyleProfile::Surface)
.metrics_role(MetricsRole::TabTrigger)
.focusable()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.key(routed_key)
.axis(Axis::Row)
.children(children)
.default_gap(tokens::SPACE_1)
.align(Align::Center)
.justify(Justify::Center)
.default_radius(tokens::RADIUS_SM)
.width(Size::Fill(1.0))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0));
let styled = if selected {
base.current()
} else {
base.ghost()
};
styled.animate(Timing::SPRING_QUICK)
}
#[track_caller]
pub fn tabs_list<I, V, L>(
key: impl Into<String>,
current: &impl std::fmt::Display,
options: I,
) -> El
where
I: IntoIterator<Item = (V, L)>,
V: std::fmt::Display,
L: Into<String>,
{
let caller = Location::caller();
let key = key.into();
let current_str = current.to_string();
let triggers: Vec<El> = options
.into_iter()
.map(|(value, label)| {
let selected = value.to_string() == current_str;
tab_trigger(&key, value, label, selected).at_loc(caller)
})
.collect();
tabs_list_container(caller, triggers)
}
#[track_caller]
pub fn tabs_list_from_triggers<I, E>(triggers: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
tabs_list_container(Location::caller(), triggers)
}
fn tabs_list_container<I, E>(caller: &'static Location<'static>, triggers: I) -> El
where
I: IntoIterator<Item = E>,
E: Into<El>,
{
let mut triggers: Vec<El> = triggers.into_iter().map(Into::into).collect();
apply_edge_radii(&mut triggers);
El::new(Kind::Custom("tabs_list"))
.at_loc(caller)
.metrics_role(MetricsRole::TabList)
.axis(Axis::Row)
.default_gap(tokens::SPACE_1)
.align(Align::Stretch)
.children(triggers)
.fill(tokens::MUTED)
.stroke(tokens::BORDER)
.default_radius(tokens::RADIUS_MD)
.default_padding(Sides::all(tokens::SPACE_1))
.width(Size::Fill(1.0))
.height(Size::Hug)
}
fn apply_edge_radii(triggers: &mut [El]) {
match triggers {
[] => {}
[only] => {
set_trigger_radius(only, Corners::all(tokens::RADIUS_MD));
}
[first, middle @ .., last] => {
set_trigger_radius(
first,
Corners {
tl: tokens::RADIUS_MD,
tr: tokens::RADIUS_SM,
br: tokens::RADIUS_SM,
bl: tokens::RADIUS_MD,
},
);
for trigger in middle {
set_trigger_radius(trigger, Corners::all(tokens::RADIUS_SM));
}
set_trigger_radius(
last,
Corners {
tl: tokens::RADIUS_SM,
tr: tokens::RADIUS_MD,
br: tokens::RADIUS_MD,
bl: tokens::RADIUS_SM,
},
);
}
}
}
fn set_trigger_radius(trigger: &mut El, radius: Corners) {
trigger.radius = radius;
trigger.explicit_radius = true;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, UiTarget};
use crate::hit_test::hit_test_target;
use crate::layout::layout;
use crate::state::UiState;
use crate::widgets::text::text;
fn click_event(key: &str) -> UiEvent {
UiEvent {
path: None,
kind: UiEventKind::Click,
key: Some(key.to_string()),
target: None,
pointer: None,
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
}
}
#[test]
fn tab_option_key_matches_widget_format() {
assert_eq!(
tab_option_key("settings", &"account"),
"settings:tab:account"
);
assert_eq!(tab_option_key("dashboard:7", &42u32), "dashboard:7:tab:42");
}
#[test]
fn tab_trigger_routes_via_tab_option_key() {
let inactive = tab_trigger("settings", "account", "Account", false);
assert_eq!(inactive.key.as_deref(), Some("settings:tab:account"));
assert!(inactive.focusable);
assert!(inactive.fill.is_none());
assert!(inactive.stroke.is_none());
let active = tab_trigger("settings", "account", "Account", true);
assert_eq!(active.fill, Some(tokens::ACCENT));
assert_eq!(active.surface_role, SurfaceRole::Current);
}
#[test]
fn tab_trigger_content_keeps_tab_treatment_for_rich_children() {
let active = tab_trigger_content(
"worktree",
"main",
[text("main").label(), text("3").caption().muted()],
true,
);
assert_eq!(active.key.as_deref(), Some("worktree:tab:main"));
assert_eq!(active.metrics_role, Some(MetricsRole::TabTrigger));
assert_eq!(active.axis, Axis::Row);
assert_eq!(active.align, Align::Center);
assert_eq!(active.justify, Justify::Center);
assert_eq!(active.gap, tokens::SPACE_1);
assert_eq!(active.children.len(), 2);
assert_eq!(active.fill, Some(tokens::ACCENT));
assert_eq!(active.surface_role, SurfaceRole::Current);
}
#[test]
fn tabs_list_marks_only_the_current_value_active() {
let list = tabs_list(
"settings",
&"appearance",
[
("account", "Account"),
("appearance", "Appearance"),
("advanced", "Advanced"),
],
);
assert_eq!(
list.key, None,
"the visual pill is not an interactive target; triggers carry the routed keys"
);
assert_eq!(list.children.len(), 3);
let [account, appearance, advanced] =
[&list.children[0], &list.children[1], &list.children[2]];
assert_eq!(account.key.as_deref(), Some("settings:tab:account"));
assert_eq!(appearance.key.as_deref(), Some("settings:tab:appearance"));
assert_eq!(advanced.key.as_deref(), Some("settings:tab:advanced"));
assert_ne!(account.surface_role, SurfaceRole::Current);
assert_eq!(appearance.surface_role, SurfaceRole::Current);
assert_ne!(advanced.surface_role, SurfaceRole::Current);
}
#[test]
fn tabs_list_compares_via_display_so_typed_values_work() {
let list = tabs_list(
"page",
&7u32,
[(0u32, "Zero"), (7u32, "Seven"), (42u32, "Forty-two")],
);
let [zero, seven, fortytwo] = [&list.children[0], &list.children[1], &list.children[2]];
assert_ne!(zero.surface_role, SurfaceRole::Current);
assert_eq!(seven.surface_role, SurfaceRole::Current);
assert_ne!(fortytwo.surface_role, SurfaceRole::Current);
}
#[test]
fn tabs_list_from_triggers_keeps_stock_pill_container() {
let list = tabs_list_from_triggers([
tab_trigger_content("worktree", "main", [text("main").label()], true),
tab_trigger("worktree", "feature", "feature", false),
]);
assert_eq!(list.key, None);
assert_eq!(list.metrics_role, Some(MetricsRole::TabList));
assert_eq!(list.axis, Axis::Row);
assert_eq!(list.children.len(), 2);
assert_eq!(list.fill, Some(tokens::MUTED));
assert_eq!(list.stroke, Some(tokens::BORDER));
}
#[test]
fn classify_event_selects_only_on_matching_route() {
assert_eq!(
classify_event(&click_event("settings:tab:account"), "settings"),
Some(TabsAction::Select("account")),
);
assert_eq!(
classify_event(&click_event("dashboard:7:tab:42"), "dashboard:7"),
Some(TabsAction::Select("42")),
);
assert_eq!(
classify_event(&click_event("settings"), "settings"),
None,
"the row's own key isn't itself a tab target",
);
assert_eq!(
classify_event(&click_event("other:tab:account"), "settings"),
None,
);
assert_eq!(
classify_event(&click_event("settings-other:tab:x"), "settings"),
None,
);
assert_eq!(
classify_event(&click_event("settings:option:x"), "settings"),
None,
);
}
#[test]
fn classify_event_ignores_non_activating_kinds() {
let mut ev = click_event("settings:tab:account");
ev.kind = UiEventKind::PointerDown;
assert_eq!(classify_event(&ev, "settings"), None);
ev.kind = UiEventKind::Drag;
assert_eq!(classify_event(&ev, "settings"), None);
ev.kind = UiEventKind::Activate;
assert_eq!(
classify_event(&ev, "settings"),
Some(TabsAction::Select("account")),
"keyboard activation should select like a click",
);
}
#[test]
fn apply_event_folds_actions_into_value() {
let mut tab = String::from("account");
assert!(apply_event(
&mut tab,
&click_event("settings:tab:advanced"),
"settings",
|s| Some(s.to_string()),
));
assert_eq!(tab, "advanced");
assert!(!apply_event(
&mut tab,
&click_event("save"),
"settings",
|s| Some(s.to_string()),
));
assert_eq!(tab, "advanced");
}
#[test]
fn apply_event_silently_ignores_unparseable_values() {
let mut tab: u32 = 1;
assert!(apply_event(
&mut tab,
&click_event("page:tab:not-a-number"),
"page",
|s| s.parse::<u32>().ok(),
));
assert_eq!(tab, 1, "value preserved when parse returns None");
}
#[test]
fn tab_trigger_animates_so_selection_changes_ease() {
assert!(
tab_trigger("settings", "account", "Account", true)
.animate
.is_some()
);
assert!(
tab_trigger("settings", "account", "Account", false)
.animate
.is_some()
);
}
#[test]
fn tabs_list_paints_a_segmented_pill_around_the_triggers() {
let list = tabs_list(
"settings",
&"account",
[("account", "Account"), ("settings", "Settings")],
);
assert_eq!(list.fill, Some(tokens::MUTED));
assert_eq!(list.radius, crate::tree::Corners::all(tokens::RADIUS_MD));
assert_eq!(
list.children[0].radius,
crate::tree::Corners {
tl: tokens::RADIUS_MD,
tr: tokens::RADIUS_SM,
br: tokens::RADIUS_SM,
bl: tokens::RADIUS_MD,
}
);
assert!(
list.children[0].explicit_radius,
"edge trigger radius must survive the metrics pass"
);
assert_eq!(
list.children[1].radius,
crate::tree::Corners {
tl: tokens::RADIUS_SM,
tr: tokens::RADIUS_MD,
br: tokens::RADIUS_MD,
bl: tokens::RADIUS_SM,
}
);
assert_eq!(list.axis, Axis::Row);
assert!(!list.focusable);
assert!(list.key.is_none());
}
#[test]
fn tabs_list_gap_is_not_a_hover_target() {
let mut list = tabs_list(
"settings",
&"account",
[("account", "Account"), ("advanced", "Advanced")],
);
let mut state = UiState::new();
layout(&mut list, &mut state, Rect::new(0.0, 0.0, 240.0, 60.0));
let first = state.rect(&list.children[0].computed_id);
let second = state.rect(&list.children[1].computed_id);
assert!(
second.x > first.x + first.w,
"test requires the tab list's configured gap to be present"
);
let trigger_target = hit_test_target(
&list,
&state,
(first.x + first.w / 2.0, first.y + first.h / 2.0),
)
.expect("tab trigger should still be interactive");
assert_eq!(trigger_target.key, "settings:tab:account");
let gap_x = (first.x + first.w + second.x) / 2.0;
let gap_y = first.y + first.h / 2.0;
assert_eq!(
hit_test_target(&list, &state, (gap_x, gap_y)),
None,
"the gap between triggers should not hover the tab-list shell"
);
}
#[test]
fn target_for_event_can_be_routed_through_apply_event() {
let ev = UiEvent {
path: None,
kind: UiEventKind::Click,
key: Some("settings:tab:advanced".into()),
target: Some(UiTarget {
key: "settings:tab:advanced".into(),
node_id: "/settings/2".into(),
rect: Rect::new(0.0, 0.0, 60.0, 28.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 1,
};
let mut tab = String::from("account");
assert!(apply_event(&mut tab, &ev, "settings", |s| Some(
s.to_string()
)));
assert_eq!(tab, "advanced");
}
}