use std::panic::Location;
use crate::anim::Timing;
use crate::event::{UiEvent, UiEventKind};
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)
.focusable()
.paint_overflow(Sides::all(tokens::FOCUS_RING_WIDTH))
.key(routed_key)
.text(label)
.text_align(TextAlign::Center)
.text_role(TextRole::Label)
.radius(tokens::RADIUS_SM)
.width(Size::Fill(1.0))
.height(Size::Fixed(28.0))
.padding(Sides::xy(tokens::SPACE_MD, 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 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)
})
.collect();
El::new(Kind::Custom("tabs_list"))
.at_loc(Location::caller())
.key(key)
.axis(Axis::Row)
.gap(tokens::SPACE_XS)
.align(Align::Stretch)
.children(triggers)
.fill(tokens::BG_MUTED)
.stroke(tokens::BORDER)
.radius(tokens::RADIUS_MD)
.padding(Sides::all(tokens::SPACE_XS))
.width(Size::Fill(1.0))
.height(Size::Hug)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, UiTarget};
fn click_event(key: &str) -> UiEvent {
UiEvent {
kind: UiEventKind::Click,
key: Some(key.to_string()),
target: None,
pointer: None,
key_press: None,
text: None,
modifiers: KeyModifiers::default(),
}
}
#[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::BG_RAISED));
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.as_deref(), Some("settings"));
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 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::BG_MUTED));
assert_eq!(list.radius, tokens::RADIUS_MD);
assert_eq!(list.axis, Axis::Row);
assert!(!list.focusable);
}
#[test]
fn target_for_event_can_be_routed_through_apply_event() {
let ev = UiEvent {
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),
}),
pointer: None,
key_press: None,
text: None,
modifiers: KeyModifiers::default(),
};
let mut tab = String::from("account");
assert!(apply_event(&mut tab, &ev, "settings", |s| Some(
s.to_string()
)));
assert_eq!(tab, "advanced");
}
}