use std::{fmt, sync::Arc};
use gpui_kit::{
App, Context, Div, ElementId, Entity, EventEmitter, Hsla, InteractiveElement as _, IntoElement,
KeyDownEvent, MouseButton, ParentElement as _, Pixels, RenderOnce, ScrollHandle, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
assets::IconName,
base::{
self, Disableable as _, ElementExt as _, Interpolate as _, Sequence, StyledExt as _,
TestSupportExt as _, h_flex, transition,
},
div, point,
prelude::FluentBuilder as _,
px,
};
use crate::{
ActiveTheme as _, Button, ButtonSize, Icon, Theme, ThemeTokens, TooltipHost,
tooltip::TooltipTrigger,
};
#[derive(Clone)]
pub struct Tab {
id: SharedString,
label: SharedString,
icon: Option<Icon>,
}
impl Tab {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
icon: None,
}
}
pub fn with_icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn icon(&self) -> Option<&Icon> {
self.icon.as_ref()
}
}
impl fmt::Debug for Tab {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tab")
.field("id", &self.id)
.field("label", &self.label)
.field("icon", &self.icon.as_ref().map(Icon::source))
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TabsEvent {
Selected(SharedString),
Closed(SharedString),
AddRequested,
}
pub struct TabsState {
tabs: Vec<Tab>,
selected: Option<SharedString>,
scroll: ScrollHandle,
glide: Option<Glide>,
reveal: bool,
entering: Vec<SharedString>,
leaving: Vec<Leaving>,
}
#[derive(Clone, Debug)]
struct Leaving {
tab: Tab,
index: usize,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Glide {
serial: usize,
from: Pixels,
to: Pixels,
}
impl EventEmitter<TabsEvent> for TabsState {}
impl TabsState {
pub fn new(tabs: impl IntoIterator<Item = Tab>) -> Self {
let tabs: Vec<Tab> = tabs.into_iter().collect();
debug_assert!(unique_ids(&tabs), "tab ids must be unique");
let selected = tabs.first().map(|tab| tab.id.clone());
Self {
tabs,
selected,
scroll: ScrollHandle::new(),
glide: None,
reveal: false,
entering: Vec::new(),
leaving: Vec::new(),
}
}
pub fn tabs(&self) -> &[Tab] {
&self.tabs
}
pub fn selected(&self) -> Option<&SharedString> {
self.selected.as_ref()
}
fn index_of(&self, id: &SharedString) -> Option<usize> {
self.tabs.iter().position(|tab| &tab.id == id)
}
fn selected_index(&self) -> Option<usize> {
self.selected.as_ref().and_then(|id| self.index_of(id))
}
pub fn select(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
let id = id.into();
let Some(index) = self.index_of(&id) else {
return;
};
if self.selected.as_ref() == Some(&id) {
return;
}
self.select_index(index);
cx.emit(TabsEvent::Selected(id));
cx.notify();
}
pub fn select_neighbor(&mut self, step: isize, cx: &mut Context<Self>) {
let len = self.tabs.len();
if len == 0 {
return;
}
let current = self.selected_index().unwrap_or(0) as isize;
let next = (current + step).rem_euclid(len as isize) as usize;
let id = self.tabs[next].id.clone();
self.select(id, cx);
}
fn select_index(&mut self, index: usize) {
self.selected = Some(self.tabs[index].id.clone());
self.reveal = true;
}
fn revealed(&mut self, cx: &mut Context<Self>) {
if self.reveal {
self.reveal = false;
cx.notify();
}
}
pub fn push(&mut self, tab: Tab, cx: &mut Context<Self>) {
debug_assert!(self.index_of(&tab.id).is_none(), "tab ids must be unique");
let id = tab.id.clone();
self.leaving.retain(|leaving| leaving.tab.id != id);
self.tabs.push(tab);
self.entering.push(id.clone());
self.select_index(self.tabs.len() - 1);
cx.emit(TabsEvent::Selected(id));
cx.notify();
}
pub fn remove(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
let id = id.into();
let Some(index) = self.index_of(&id) else {
return;
};
let was_selected = self.selected.as_ref() == Some(&id);
let tab = self.tabs.remove(index);
self.entering.retain(|entering| entering != &id);
self.leaving.push(Leaving { tab, index });
let next = if was_selected {
self.selected = None;
next_selection(index, self.tabs.len())
} else {
None
};
if let Some(next) = next {
self.select_index(next);
}
cx.emit(TabsEvent::Closed(id));
if let Some(next) = next {
cx.emit(TabsEvent::Selected(self.tabs[next].id.clone()));
}
cx.notify();
}
pub fn request_add(&mut self, cx: &mut Context<Self>) {
cx.emit(TabsEvent::AddRequested);
}
fn settle(&mut self, id: &SharedString, cx: &mut Context<Self>) {
let before = (self.entering.len(), self.leaving.len());
self.entering.retain(|entering| entering != id);
self.leaving.retain(|leaving| &leaving.tab.id != id);
if before != (self.entering.len(), self.leaving.len()) {
cx.notify();
}
}
fn scroll_by(&mut self, delta: Pixels, cx: &mut Context<Self>) {
let from = self.scroll.offset().x;
self.glide_to(from, from - delta, cx);
}
fn wheel_by(&mut self, step: Pixels, cx: &mut Context<Self>) {
let now = self.scroll.offset();
let from = now.x - step;
self.scroll.set_offset(point(from, now.y));
let base = match self.glide {
Some(glide)
if (glide.from..=glide.to).contains(&from)
|| (glide.to..=glide.from).contains(&from) =>
{
glide.to
}
_ => from,
};
self.glide_to(from, base + step, cx);
}
fn glide_to(&mut self, from: Pixels, to: Pixels, cx: &mut Context<Self>) {
let max = self.scroll.max_offset().x;
let to = to.clamp(-max, px(0.));
if to != from {
let serial = self.glide.map_or(0, |glide| glide.serial + 1);
self.glide = Some(Glide { serial, from, to });
cx.notify();
}
}
}
fn unique_ids(tabs: &[Tab]) -> bool {
tabs.iter()
.enumerate()
.all(|(index, tab)| !tabs[..index].iter().any(|other| other.id == tab.id))
}
fn next_selection(removed: usize, len: usize) -> Option<usize> {
if len == 0 {
None
} else if removed < len {
Some(removed)
} else {
Some(len - 1)
}
}
fn separator_after(index: usize, selected_index: Option<usize>, len: usize) -> bool {
if index + 1 >= len {
return false;
}
match selected_index {
Some(selected) => index != selected && index + 1 != selected,
None => true,
}
}
#[derive(IntoElement)]
pub struct Tabs {
id: ElementId,
state: Entity<TabsState>,
style: StyleRefinement,
closable: bool,
addable: bool,
leading: Pixels,
}
impl Tabs {
pub fn new(id: impl Into<ElementId>, state: &Entity<TabsState>) -> Self {
Self {
id: id.into(),
state: state.clone(),
style: StyleRefinement::default(),
closable: true,
addable: true,
leading: px(0.),
}
}
pub fn with_leading(mut self, leading: impl Into<Pixels>) -> Self {
self.leading = leading.into();
self
}
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn addable(mut self, addable: bool) -> Self {
self.addable = addable;
self
}
}
impl Styled for Tabs {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
struct Look {
touch: bool,
bar: Hsla,
outline: Hsla,
hover: Hsla,
foreground: Hsla,
muted_foreground: Hsla,
separator: Hsla,
ring: Hsla,
surface_inset: Pixels,
width: Pixels,
min_width: Pixels,
shoulder: Pixels,
radius: Pixels,
gutter: Pixels,
separator_inset: Pixels,
icon: Pixels,
gap: Pixels,
control: Pixels,
close: Pixels,
text_size: Pixels,
line_height: Pixels,
edge: Pixels,
control_gap: Pixels,
}
impl Look {
fn of(theme: &ThemeTokens) -> Self {
let metrics = &theme.metrics;
Self {
touch: theme.touch,
bar: theme.background(),
outline: theme.border(),
hover: theme.muted(),
foreground: theme.foreground(),
muted_foreground: theme.muted_foreground(),
separator: theme.tab_separator,
ring: theme.ring(),
surface_inset: metrics.tab_surface_inset,
width: metrics.tab_width,
min_width: metrics.tab_min_width,
shoulder: metrics.tab_shoulder,
radius: metrics.tab_radius,
gutter: metrics.tab_separator_gutter,
separator_inset: metrics.tab_separator_inset,
icon: metrics.tab_icon,
gap: metrics.tab_content_gap,
control: metrics.tab_control,
close: metrics.tab_close,
text_size: theme.text_control.size,
line_height: theme.text_control.line_height,
edge: theme.base.spacing.sm,
control_gap: theme.base.spacing.xs,
}
}
}
struct Slot<'a> {
tab: &'a Tab,
live: Option<usize>,
entering: bool,
}
#[derive(Clone, Copy, PartialEq)]
struct Overflow {
overflowing: bool,
at_start: bool,
at_end: bool,
}
impl Overflow {
fn of(scroll: &ScrollHandle) -> Self {
let max = scroll.max_offset().x;
let offset = scroll.offset().x;
Self {
overflowing: max > px(0.),
at_start: offset >= px(0.),
at_end: offset <= -max,
}
}
}
fn slot(look: &Look, button: Button) -> impl IntoElement {
h_flex()
.w(look.control)
.h_full()
.pt(look.surface_inset)
.flex_none()
.justify_center()
.border_b_1()
.border_color(look.outline)
.child(button)
}
fn room(look: &Look, width: Pixels) -> Div {
div()
.flex_none()
.h_full()
.w(width)
.border_b_1()
.border_color(look.outline)
}
fn hairline(look: &Look) -> impl IntoElement {
room(look, look.edge).flex_1().min_w(look.edge)
}
impl RenderOnce for Tabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let look = Look::of(cx.theme());
let strip: Arc<ElementId> = Arc::new(self.id.clone());
let child = |name: &'static str| ElementId::NamedChild(strip.clone(), name.into());
let state = self.state;
let (tabs, selected_index, scroll, glide, reveal, entering, leaving) = {
let state = state.read(cx);
(
state.tabs.clone(),
state.selected_index(),
state.scroll.clone(),
state.glide,
state.reveal,
state.entering.clone(),
state.leaving.clone(),
)
};
let len = tabs.len();
let (motion, fast) = {
let motion = &Theme::global(cx).motion;
(motion.glide_transition(), motion.fast_transition())
};
if let Some(glide) = glide {
let key = (child("glide"), SharedString::from(glide.serial.to_string()));
let sample = Sequence::new(key, glide.from)
.with_step(glide.to, motion.clone())
.sample(window, cx);
let landed = window.use_keyed_state(child("landed"), cx, |_, _| None::<usize>);
if !sample.is_finished() || *landed.read(cx) != Some(glide.serial) {
scroll.set_offset(point(*sample.value(), scroll.offset().y));
if sample.is_finished() {
landed.update(cx, |landed, _| *landed = Some(glide.serial));
}
}
}
let overflow = Overflow::of(&scroll);
let has_overlay = TooltipHost::overlay(window, cx).is_some();
let focus = window
.use_keyed_state(child("focus"), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
let focus_visible = focus.is_focused(window) && window.last_input_was_keyboard();
let mut slots: Vec<Slot> = tabs
.iter()
.enumerate()
.map(|(index, tab)| Slot {
tab,
live: Some(index),
entering: entering.contains(&tab.id),
})
.collect();
for leaving in &leaving {
let at = leaving.index.min(slots.len());
slots.insert(
at,
Slot {
tab: &leaving.tab,
live: None,
entering: false,
},
);
}
let selected_child = slots
.iter()
.position(|slot| slot.live == selected_index && slot.live.is_some())
.map(|position| position + 1);
if reveal {
if let Some(child) = selected_child {
scroll.scroll_to_item(child);
}
let revealed = state.clone();
cx.defer(move |cx| revealed.update(cx, |state, cx| state.revealed(cx)));
}
let settle_state = state.clone();
let items = slots.into_iter().map(|slot| {
let tab = slot.tab;
let index = slot.live.unwrap_or(0);
let live = slot.live.is_some();
let selected = live && selected_index == Some(index);
let tab_id = Arc::new(ElementId::NamedChild(strip.clone(), tab.id.clone()));
let width = if slot.entering || !live {
let (from, to) = if live {
(px(0.), look.width)
} else {
(look.width, px(0.))
};
let sample = Sequence::new((tab_id.as_ref().clone(), "width"), from)
.with_step(to, motion.clone())
.sample(window, cx);
if sample.is_finished() {
let settle_state = settle_state.clone();
let id = tab.id.clone();
cx.defer(move |cx| settle_state.update(cx, |state, cx| state.settle(&id, cx)));
}
Some(*sample.value())
} else {
None
};
let part = |name: &'static str| ElementId::NamedChild(tab_id.clone(), name.into());
let group = tab.id.clone();
let separator = live && separator_after(index, selected_index, len);
let gutter = if separator { look.gutter } else { px(0.) };
let hovered = window
.use_keyed_state(part("hovered"), cx, |_, _| false)
.clone();
let hovers = live && !selected && !look.touch && *hovered.read(cx);
let selection = transition(
part("selection"),
f32::from(selected),
fast.clone(),
window,
cx,
);
let hover = transition(part("hover"), f32::from(hovers), fast.clone(), window, cx);
let text = look
.muted_foreground
.interpolate(&look.foreground, selection);
let outline_color = if selected && focus_visible {
look.ring
} else {
look.outline
}
.opacity(selection);
let hairline_color = look.outline.opacity(1. - selection);
let hover_color = look.hover.opacity(hover);
let shoulder = |side_left: bool| {
div()
.absolute()
.bottom_0()
.size(look.shoulder)
.overflow_hidden()
.bg(hover_color)
.map(|this| {
if side_left {
this.left_0()
} else {
this.right(gutter)
}
})
.child(
div()
.absolute()
.size(look.shoulder * 2.)
.rounded_full()
.top(-look.shoulder)
.bg(look.bar)
.border_1()
.border_color(outline_color)
.map(|this| {
if side_left {
this.left(-look.shoulder)
} else {
this.right(-look.shoulder)
}
}),
)
};
let label_id = part("label");
let tooltip = TooltipTrigger::text(label_id.clone(), None, tab.label.clone());
let select_state = state.clone();
let select_id = tab.id.clone();
let close_state = state.clone();
let close_id = tab.id.clone();
base::Tab::new(tab_id.as_ref().clone())
.selected(selected)
.set_position(index + 1, len)
.accessibility_label(tab.label.clone())
.group(group.clone())
.relative()
.flex_shrink(1.)
.h_full()
.map(|this| match width {
Some(width) => this
.w(width)
.min_w(width.min(look.min_width))
.overflow_hidden(),
None => this.w(look.width).min_w(look.min_width),
})
.border_b_1()
.border_color(hairline_color)
.when(live, |this| {
this.on_hover(move |is_hovered, _, cx| {
hovered.update(cx, |hovered, cx| {
if *hovered != *is_hovered {
*hovered = *is_hovered;
cx.notify();
}
})
})
})
.when(live, |this| {
this.on_click(move |_, _, cx| {
select_state.update(cx, |state, cx| state.select(select_id.clone(), cx))
})
})
.child(
div()
.absolute()
.top(look.surface_inset)
.bottom_0()
.left_0()
.right_0()
.child(shoulder(true))
.child(shoulder(false))
.child(
div()
.absolute()
.top_0()
.bottom(look.shoulder)
.left(look.shoulder - px(1.))
.right(look.shoulder + gutter - px(1.))
.rounded_t(look.radius)
.border_1()
.border_b_0()
.border_color(outline_color),
)
.child(
h_flex()
.id(label_id)
.test_support()
.absolute()
.top_0()
.bottom_0()
.left(look.shoulder)
.right(look.shoulder + gutter)
.min_w_0()
.rounded_t(look.radius)
.overflow_hidden()
.bg(hover_color)
.px_2()
.gap(look.gap)
.text_color(text)
.map(|this| {
if has_overlay {
this.on_prepaint(tooltip.track_bounds())
.on_hover(move |hovered, window, cx| {
tooltip.hovered(*hovered, window, cx)
})
.on_mouse_down(MouseButton::Left, |_, window, cx| {
TooltipTrigger::pressed(window, cx)
})
} else {
this.tooltip(tooltip.native())
}
})
.when_some(tab.icon.clone(), |this, icon| {
this.child(icon.size(look.icon).flex_none())
})
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(look.text_size)
.line_height(look.line_height)
.child(tab.label.clone()),
)
.when(self.closable && live, |this| {
this.child(
h_flex()
.size(look.close)
.flex_none()
.justify_center()
.when(!selected && !look.touch, |this| {
this.invisible()
.group_hover(group, |style| style.visible())
})
.child(
Button::new(part("close"))
.ghost()
.size(ButtonSize::Xs)
.icon(IconName::Close)
.rounded_full()
.accessibility_label("Close tab")
.tooltip("Close tab")
.tab_stop(false)
.on_click(move |_, _, cx| {
cx.stop_propagation();
close_state.update(cx, |state, cx| {
state.remove(close_id.clone(), cx)
})
}),
),
)
}),
),
)
.when(separator, |this| {
this.child(
div()
.absolute()
.top(look.separator_inset)
.bottom(look.separator_inset)
.right((look.gutter - px(1.)) / 2.)
.w(px(1.))
.bg(look.separator),
)
})
});
let prepaint_scroll = scroll.clone();
let wheel_scroll = scroll.clone();
let wheel_state = state.clone();
let viewport = div()
.on_children_prepainted(move |_, window, _| {
if Overflow::of(&prepaint_scroll) != overflow {
if let Some(child) = selected_child {
prepaint_scroll.scroll_to_item(child);
}
window.request_animation_frame();
}
})
.on_scroll_wheel(move |event, window, cx| {
if wheel_scroll.max_offset().x > px(0.) {
cx.stop_propagation();
}
if event.delta.precise() {
return;
}
let delta = event.delta.pixel_delta(window.line_height());
let step = if delta.x != px(0.) { delta.x } else { delta.y };
if step != px(0.) {
wheel_state.update(cx, |state, cx| state.wheel_by(step, cx));
}
})
.id(child("scroll"))
.test_support()
.flex()
.items_end()
.h_full()
.min_w_0()
.overflow_x_scroll()
.track_scroll(&scroll)
.child(room(&look, look.edge))
.children(items);
let back_state = state.clone();
let forward_state = state.clone();
let add_state = state.clone();
let key_state = state;
div()
.id(self.id)
.test_support()
.w_full()
.h_full()
.min_w_0()
.bg(look.bar)
.track_focus(&focus)
.tab_index(0)
.on_key_down(move |event: &KeyDownEvent, _, cx| {
if event.keystroke.modifiers.modified() {
return;
}
let step: isize = match event.keystroke.key.as_str() {
"left" => -1,
"right" => 1,
_ => return,
};
key_state.update(cx, |state, cx| state.select_neighbor(step, cx));
cx.stop_propagation();
})
.refine_style(&self.style)
.child(
base::Tabs::new(child("list"))
.flex()
.items_end()
.size_full()
.when(self.leading > px(0.), |this| {
this.child(room(&look, self.leading))
})
.when(overflow.overflowing, |this| {
this.child(room(&look, look.edge))
.child(slot(
&look,
Button::new(child("back"))
.ghost()
.size(ButtonSize::Sm)
.icon(IconName::ChevronLeft)
.accessibility_label("Scroll tabs left")
.tooltip("Scroll tabs left")
.tab_stop(false)
.disabled(overflow.at_start)
.on_click(move |_, _, cx| {
back_state.update(cx, |state, cx| {
state.scroll_by(-look.width, cx)
})
}),
))
.child(room(&look, look.control_gap))
.child(slot(
&look,
Button::new(child("forward"))
.ghost()
.size(ButtonSize::Sm)
.icon(IconName::ChevronRight)
.accessibility_label("Scroll tabs right")
.tooltip("Scroll tabs right")
.tab_stop(false)
.disabled(overflow.at_end)
.on_click(move |_, _, cx| {
forward_state
.update(cx, |state, cx| state.scroll_by(look.width, cx))
}),
))
.child(room(&look, look.edge))
})
.child(viewport)
.when(self.addable, |this| {
this.child(room(&look, look.edge)).child(slot(
&look,
Button::new(child("add"))
.ghost()
.size(ButtonSize::Sm)
.icon(IconName::Plus)
.accessibility_label("New tab")
.tooltip("New tab")
.on_click(move |_, _, cx| {
add_state.update(cx, |state, cx| state.request_add(cx))
}),
))
})
.child(hairline(&look)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::{AppContext as _, TestAppContext};
fn ids(state: &TabsState) -> Vec<&str> {
state.tabs.iter().map(|tab| tab.id.as_ref()).collect()
}
#[test]
fn removing_the_selected_tab_selects_the_next_or_the_previous() {
assert_eq!(next_selection(0, 2), Some(0), "the tab that took its place");
assert_eq!(next_selection(1, 2), Some(1));
assert_eq!(
next_selection(2, 2),
Some(1),
"the last tab: the one before"
);
assert_eq!(next_selection(0, 0), None, "nothing left");
}
#[test]
fn a_separator_follows_a_tab_away_from_the_selection_and_the_end() {
assert!(separator_after(0, Some(2), 5));
assert!(!separator_after(1, Some(2), 5), "before the selected tab");
assert!(!separator_after(2, Some(2), 5), "the selected tab");
assert!(separator_after(3, Some(2), 5));
assert!(!separator_after(4, Some(2), 5), "the last tab");
assert!(separator_after(0, None, 2), "no selection");
assert!(!separator_after(0, None, 1));
}
#[gpui_kit::test]
fn the_state_selects_pushes_and_removes(cx: &mut TestAppContext) {
let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update({
let events = events.clone();
let state = state.clone();
move |cx| {
cx.subscribe(&state, move |_, event: &TabsEvent, _| {
events.borrow_mut().push(event.clone())
})
.detach();
}
});
assert_eq!(
state.read_with(cx, |state, _| state.selected().cloned()),
Some("a".into())
);
state.update(cx, |state, cx| {
state.select("b", cx);
state.select("b", cx);
state.select("missing", cx);
state.push(Tab::new("c", "C"), cx);
state.remove("c", cx);
state.remove("a", cx);
state.request_add(cx);
assert_eq!(ids(state), ["b"]);
assert_eq!(state.selected(), Some(&"b".into()));
state.remove("b", cx);
assert!(state.selected().is_none());
});
cx.run_until_parked();
assert_eq!(
*events.borrow(),
[
TabsEvent::Selected("b".into()),
TabsEvent::Selected("c".into()),
TabsEvent::Closed("c".into()),
TabsEvent::Selected("b".into()),
TabsEvent::Closed("a".into()),
TabsEvent::AddRequested,
TabsEvent::Closed("b".into()),
]
);
}
#[gpui_kit::test]
fn a_neighbor_step_wraps_at_both_ends(cx: &mut TestAppContext) {
let state = cx.new(|_| TabsState::new([Tab::new("a", "A"), Tab::new("b", "B")]));
state.update(cx, |state, cx| {
state.select_neighbor(1, cx);
assert_eq!(state.selected(), Some(&"b".into()));
state.select_neighbor(1, cx);
assert_eq!(state.selected(), Some(&"a".into()), "wraps");
state.select_neighbor(-1, cx);
assert_eq!(state.selected(), Some(&"b".into()), "wraps back");
});
let empty = cx.new(|_| TabsState::new([]));
empty.update(cx, |state, cx| state.select_neighbor(1, cx));
}
#[test]
fn a_tab_reads_back_and_debugs_without_its_icon_bytes() {
let tab = Tab::new("t", "Terminal").with_icon(IconName::SquareTerminal);
assert_eq!(tab.id(), &SharedString::from("t"));
assert_eq!(tab.label(), &SharedString::from("Terminal"));
assert!(tab.icon().is_some());
assert!(format!("{tab:?}").contains("square-terminal"));
}
}