use std::panic::Location;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind};
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::button::icon_button;
use crate::{IconName, text};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ActiveTabStyle {
#[default]
Lifted,
TopAccent,
BottomRule,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CloseVisibility {
#[default]
ActiveOrHover,
Always,
Dimmed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct EditorTabsConfig {
pub active_style: ActiveTabStyle,
pub close_visibility: CloseVisibility,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum EditorTabsAction<'a> {
Select(&'a str),
Close(&'a str),
Add,
}
pub fn editor_tab_select_key(key: &str, value: &impl std::fmt::Display) -> String {
format!("{key}:tab:{value}")
}
pub fn editor_tab_close_key(key: &str, value: &impl std::fmt::Display) -> String {
format!("{key}:close:{value}")
}
pub fn editor_tab_add_key(key: &str) -> String {
format!("{key}:add")
}
pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<EditorTabsAction<'a>> {
if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
return None;
}
let routed = event.route()?;
let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
if let Some(value) = rest.strip_prefix("tab:") {
return Some(EditorTabsAction::Select(value));
}
if let Some(value) = rest.strip_prefix("close:") {
return Some(EditorTabsAction::Close(value));
}
if rest == "add" {
return Some(EditorTabsAction::Add);
}
None
}
pub fn apply_event<V>(
tabs: &mut Vec<V>,
active: &mut V,
event: &UiEvent,
key: &str,
parse: impl Fn(&str) -> Option<V>,
mint_new: impl FnOnce() -> V,
) -> bool
where
V: Clone + PartialEq,
{
match classify_event(event, key) {
Some(EditorTabsAction::Select(raw)) => {
if let Some(v) = parse(raw) {
*active = v;
}
true
}
Some(EditorTabsAction::Close(raw)) => {
let Some(target) = parse(raw) else {
return true;
};
let Some(index) = tabs.iter().position(|t| *t == target) else {
return true;
};
if tabs.len() <= 1 {
return true;
}
let was_active = *active == target;
tabs.remove(index);
if was_active {
let next = index.min(tabs.len() - 1);
*active = tabs[next].clone();
}
true
}
Some(EditorTabsAction::Add) => {
let new = mint_new();
*active = new.clone();
tabs.push(new);
true
}
None => false,
}
}
#[track_caller]
pub fn editor_tab(
strip_key: &str,
value: impl std::fmt::Display,
leading: Option<El>,
label: impl Into<String>,
selected: bool,
config: EditorTabsConfig,
) -> El {
let select_key = editor_tab_select_key(strip_key, &value);
let close_key = editor_tab_close_key(strip_key, &value);
let label_el = text(label).label().ellipsis().text_color(if selected {
tokens::FOREGROUND
} else {
tokens::MUTED_FOREGROUND
});
let mut close = icon_button(IconName::X)
.key(close_key)
.icon_size(tokens::ICON_XS)
.ghost()
.width(Size::Fixed(tokens::SPACE_5))
.height(Size::Fixed(tokens::SPACE_5));
if !selected {
let rest = match config.close_visibility {
CloseVisibility::ActiveOrHover => 0.0,
CloseVisibility::Dimmed => 0.4,
CloseVisibility::Always => 1.0,
};
if rest < 1.0 {
close = close.hover_alpha(rest, 1.0);
}
}
let mut body_children: Vec<El> = Vec::with_capacity(3);
if let Some(leading) = leading {
body_children.push(leading);
}
body_children.push(label_el);
body_children.push(close);
let body = row(body_children)
.gap(tokens::SPACE_2)
.align(Align::Center)
.padding(Sides::xy(tokens::SPACE_3, 0.0))
.height(Size::Fill(1.0));
let rule = || {
let mut el = El::new(Kind::Custom("editor_tab_accent_rule"))
.height(Size::Fixed(2.0))
.width(Size::Fill(1.0));
if selected {
el = el.fill(tokens::PRIMARY);
}
el
};
let stack = match config.active_style {
ActiveTabStyle::Lifted => column([body]),
ActiveTabStyle::TopAccent => column([rule(), body]),
ActiveTabStyle::BottomRule => column([body, rule()]),
};
let mut tab = stack
.at_loc(Location::caller())
.key(select_key)
.style_profile(StyleProfile::Solid)
.focusable()
.cursor(Cursor::Pointer)
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.axis(Axis::Column)
.align(Align::Stretch)
.height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
.width(Size::Hug);
if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
}
tab
}
#[track_caller]
pub fn editor_tabs<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>,
{
editor_tabs_with(key, current, options, EditorTabsConfig::default())
}
#[track_caller]
pub fn editor_tabs_with<I, V, L>(
key: impl Into<String>,
current: &impl std::fmt::Display,
options: I,
config: EditorTabsConfig,
) -> 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 mut children: Vec<El> = options
.into_iter()
.map(|(value, label)| {
let selected = value.to_string() == current_str;
editor_tab(&key, value, None, label, selected, config).at_loc(caller)
})
.collect();
let add_key = editor_tab_add_key(&key);
let add_btn = icon_button(IconName::Plus)
.at_loc(caller)
.key(add_key)
.icon_size(tokens::ICON_SM)
.ghost()
.width(Size::Fixed(tokens::CONTROL_HEIGHT))
.height(Size::Fixed(tokens::CONTROL_HEIGHT));
children.push(add_btn);
El::new(Kind::Custom("editor_tabs"))
.at_loc(caller)
.axis(Axis::Row)
.default_gap(tokens::SPACE_1)
.align(Align::Center)
.children(children)
.fill(tokens::MUTED)
.default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
.width(Size::Fill(1.0))
.height(Size::Hug)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::KeyModifiers;
fn click(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 key_helpers_match_widget_format() {
assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
assert_eq!(editor_tab_add_key("docs"), "docs:add");
}
#[test]
fn classify_event_recognises_all_three_actions() {
assert_eq!(
classify_event(&click("docs:tab:readme"), "docs"),
Some(EditorTabsAction::Select("readme")),
);
assert_eq!(
classify_event(&click("docs:close:readme"), "docs"),
Some(EditorTabsAction::Close("readme")),
);
assert_eq!(
classify_event(&click("docs:add"), "docs"),
Some(EditorTabsAction::Add),
);
assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
assert_eq!(classify_event(&click("docs"), "docs"), None);
}
#[test]
fn classify_event_ignores_non_activating_kinds() {
let mut ev = click("docs:close:readme");
ev.kind = UiEventKind::PointerDown;
assert_eq!(classify_event(&ev, "docs"), None);
ev.kind = UiEventKind::Activate;
assert_eq!(
classify_event(&ev, "docs"),
Some(EditorTabsAction::Close("readme")),
"keyboard activation should fire close like a click",
);
}
#[test]
fn editor_tab_routes_via_select_key() {
let tab = editor_tab(
"docs",
"readme",
None,
"README.md",
false,
EditorTabsConfig::default(),
);
assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
assert!(tab.focusable);
}
#[test]
fn editor_tab_active_lifted_fills_with_card() {
let active = editor_tab(
"docs",
"readme",
None,
"README.md",
true,
EditorTabsConfig::default(),
);
let inactive = editor_tab(
"docs",
"readme",
None,
"README.md",
false,
EditorTabsConfig::default(),
);
assert_eq!(active.fill, Some(tokens::CARD));
assert_eq!(
inactive.fill, None,
"inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
);
}
#[test]
fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
let cfg = EditorTabsConfig {
active_style: ActiveTabStyle::TopAccent,
..Default::default()
};
let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
assert!(active.children.len() >= 2);
assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
}
#[test]
fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
let cfg = EditorTabsConfig {
active_style: ActiveTabStyle::BottomRule,
..Default::default()
};
let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
let last = active.children.last().expect("at least one child");
assert_eq!(last.fill, Some(tokens::PRIMARY));
}
#[test]
fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
let cfg = EditorTabsConfig {
active_style: ActiveTabStyle::TopAccent,
..Default::default()
};
let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
assert_eq!(inactive.children[0].fill, None);
}
#[test]
fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
let cfg = EditorTabsConfig {
close_visibility: CloseVisibility::ActiveOrHover,
..Default::default()
};
let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
let active_body = &active.children[0];
let inactive_body = &inactive.children[0];
assert_eq!(active_body.children.len(), 2);
assert_eq!(inactive_body.children.len(), 2);
let active_close = &active_body.children[1];
assert_eq!(active_close.hover_alpha, None);
let inactive_close = &inactive_body.children[1];
let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
assert_eq!(cfg.rest, 0.0);
assert_eq!(cfg.peak, 1.0);
}
#[test]
fn close_visibility_dimmed_uses_partial_rest_opacity() {
let cfg = EditorTabsConfig {
close_visibility: CloseVisibility::Dimmed,
..Default::default()
};
let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
let body = &inactive.children[0];
let close = &body.children[1];
match close.hover_alpha {
Some(cfg) => {
assert!(
cfg.rest > 0.0 && cfg.rest < 1.0,
"Dimmed rest should be partial; got {}",
cfg.rest,
);
assert_eq!(cfg.peak, 1.0);
}
None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
}
}
#[test]
fn close_visibility_always_skips_hover_alpha() {
let cfg = EditorTabsConfig {
close_visibility: CloseVisibility::Always,
..Default::default()
};
let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
let body = &inactive.children[0];
let close = &body.children[1];
assert_eq!(close.hover_alpha, None);
}
#[test]
fn editor_tab_leading_prepends_inside_the_body_row() {
let dot = crate::tree::column([crate::widgets::text::text("●")])
.width(Size::Fixed(8.0))
.height(Size::Fixed(8.0));
let tab = editor_tab(
"docs",
"readme",
Some(dot),
"README.md",
false,
EditorTabsConfig::default(),
);
let body = &tab.children[0];
assert_eq!(body.children.len(), 3);
}
#[test]
fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
let strip = editor_tabs(
"docs",
&"readme",
[("readme", "README.md"), ("main", "main.rs")],
);
assert_eq!(strip.children.len(), 3);
let add = strip.children.last().unwrap();
assert_eq!(add.key.as_deref(), Some("docs:add"));
}
#[test]
fn editor_tabs_marks_only_the_current_value_active() {
let strip = editor_tabs(
"docs",
&"main",
[
("readme", "README.md"),
("main", "main.rs"),
("cargo", "Cargo.toml"),
],
);
assert_eq!(strip.children[0].fill, None);
assert_eq!(strip.children[1].fill, Some(tokens::CARD));
assert_eq!(strip.children[2].fill, None);
}
#[test]
fn apply_event_select_swaps_active_without_touching_tabs() {
let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let mut active = "a".to_string();
let next_id = || "fresh".to_string();
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:tab:b"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(active, "b");
assert_eq!(tabs, vec!["a", "b", "c"]);
}
#[test]
fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let mut active = "b".to_string();
let next_id = || "fresh".to_string();
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:close:b"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(tabs, vec!["a", "c"]);
assert_eq!(active, "c");
}
#[test]
fn apply_event_close_last_tab_picks_previous_neighbour() {
let mut tabs = vec!["a".to_string(), "b".to_string()];
let mut active = "b".to_string();
let next_id = || "fresh".to_string();
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:close:b"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(tabs, vec!["a"]);
assert_eq!(active, "a");
}
#[test]
fn apply_event_close_inactive_tab_leaves_active_alone() {
let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let mut active = "a".to_string();
let next_id = || "fresh".to_string();
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:close:c"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(tabs, vec!["a", "b"]);
assert_eq!(active, "a");
}
#[test]
fn apply_event_refuses_to_close_the_last_tab() {
let mut tabs = vec!["a".to_string()];
let mut active = "a".to_string();
let next_id = || "fresh".to_string();
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:close:a"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(
tabs,
vec!["a"],
"the last tab can't be closed via the helper"
);
assert_eq!(active, "a");
}
#[test]
fn apply_event_add_appends_and_activates_a_minted_tab() {
let mut tabs = vec!["a".to_string()];
let mut active = "a".to_string();
let mut counter = 0;
let next_id = || {
counter += 1;
format!("new-{counter}")
};
assert!(apply_event(
&mut tabs,
&mut active,
&click("docs:add"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(tabs, vec!["a", "new-1"]);
assert_eq!(active, "new-1");
}
#[test]
fn apply_event_returns_false_for_foreign_events() {
let mut tabs = vec!["a".to_string()];
let mut active = "a".to_string();
let next_id = || "fresh".to_string();
assert!(!apply_event(
&mut tabs,
&mut active,
&click("save"),
"docs",
|s| Some(s.to_string()),
next_id,
));
assert_eq!(tabs, vec!["a"]);
assert_eq!(active, "a");
}
}