use crate::event::Rect;
use crate::types::{Color, Icon, Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabBar {
pub id: WidgetId,
pub tabs: Vec<TabItem>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub right_segments: Vec<TabBarSegment>,
#[serde(default)]
pub active_accent: Option<Color>,
#[serde(default = "default_true")]
pub show_tab_close: bool,
#[serde(default)]
pub compact: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabIcon {
pub glyph: String,
pub color: Color,
}
impl TabIcon {
pub fn cols(&self) -> u16 {
crate::text_util::display_width(&self.glyph) as u16 + 1
}
}
pub fn tab_icon_at(icons: &[Option<TabIcon>], idx: usize) -> Option<&TabIcon> {
icons.get(idx).and_then(|slot| slot.as_ref())
}
pub fn tab_icon_cols(icons: &[Option<TabIcon>], idx: usize) -> u16 {
tab_icon_at(icons, idx).map_or(0, TabIcon::cols)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabItem {
pub label: String,
#[serde(default)]
pub is_active: bool,
#[serde(default)]
pub is_dirty: bool,
#[serde(default)]
pub is_preview: bool,
#[serde(default = "default_true")]
pub is_closable: bool,
}
impl Default for TabItem {
fn default() -> Self {
Self {
label: String::new(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabBarSegment {
pub text: String,
pub width_cells: u16,
#[serde(default)]
pub id: Option<WidgetId>,
#[serde(default)]
pub is_active: bool,
}
impl TabBar {
pub fn fit_active_scroll_offset<F>(
active: usize,
tab_count: usize,
available_width: usize,
measure: F,
) -> usize
where
F: Fn(usize) -> usize,
{
if tab_count == 0 || active >= tab_count {
return 0;
}
let mut used = 0;
let mut from_zero = 0;
for i in 0..tab_count {
let w = measure(i);
if used + w > available_width {
break;
}
used += w;
from_zero += 1;
}
if active < from_zero {
return 0;
}
let mut used = 0;
let mut best_offset = active;
for i in (0..=active).rev() {
let w = measure(i);
if used + w > available_width {
break;
}
used += w;
best_offset = i;
}
best_offset
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TabMeasure {
pub total_width: f32,
pub close_width: f32,
pub trailing_width: f32,
}
impl TabMeasure {
pub fn new(total_width: f32, close_width: f32) -> Self {
Self {
total_width,
close_width,
trailing_width: 0.0,
}
}
pub fn with_trailing(mut self, trailing_width: f32) -> Self {
self.trailing_width = trailing_width;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SegmentMeasure {
pub width: f32,
}
impl SegmentMeasure {
pub fn new(width: f32) -> Self {
Self { width }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleTab {
pub tab_idx: usize,
pub bounds: Rect,
pub close_bounds: Option<Rect>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleSegment {
pub segment_idx: usize,
pub bounds: Rect,
pub clickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TabBarHit {
Tab(usize),
TabClose(usize),
ScrollLeft,
ScrollRight,
RightSegment(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TabBarLayout {
pub bar_width: f32,
pub bar_height: f32,
pub visible_tabs: Vec<VisibleTab>,
pub visible_segments: Vec<VisibleSegment>,
pub scroll_left: Option<Rect>,
pub scroll_right: Option<Rect>,
pub hit_regions: Vec<(Rect, TabBarHit)>,
pub resolved_scroll_offset: usize,
}
impl TabBarLayout {
pub fn hit_test(&self, x: f32, y: f32) -> TabBarHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
TabBarHit::Empty
}
pub fn tab_center(&self, tab_idx: usize) -> Option<(f32, f32)> {
self.visible_tabs
.iter()
.find(|vt| vt.tab_idx == tab_idx)
.map(|vt| {
(
vt.bounds.x + vt.bounds.width / 2.0,
vt.bounds.y + vt.bounds.height / 2.0,
)
})
}
pub fn tab_close_center(&self, tab_idx: usize) -> Option<(f32, f32)> {
self.visible_tabs
.iter()
.find(|vt| vt.tab_idx == tab_idx)
.and_then(|vt| vt.close_bounds)
.map(|cb| (cb.x + cb.width / 2.0, cb.y + cb.height / 2.0))
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TabFrame {
#[default]
None,
Brackets,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TabChrome {
#[serde(default)]
pub active_frame: TabFrame,
}
impl TabChrome {
pub fn new(active_frame: TabFrame) -> Self {
Self { active_frame }
}
pub fn with_active_frame(mut self, active_frame: TabFrame) -> Self {
self.active_frame = active_frame;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct TabBarGlyphs {
close: Option<Icon>,
dirty: Option<Icon>,
}
impl TabBarGlyphs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_close_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.close = Some(glyph.into());
self
}
#[must_use]
pub fn with_dirty_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.dirty = Some(glyph.into());
self
}
#[must_use]
pub fn close_glyph(&self) -> Option<&Icon> {
self.close.as_ref()
}
#[must_use]
pub fn dirty_glyph(&self) -> Option<&Icon> {
self.dirty.as_ref()
}
}
#[deprecated(
since = "0.0.1",
note = "f64-tuple pre-D6 hit struct; the eventual replacement is `TabBarLayout` (`Rect` + `TabBarHit`) — issue #823; removal target: the #823 six-method/four-backend signature swap, not before v0.2.0"
)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TabBarHits {
pub slot_positions: Vec<(f64, f64)>,
pub close_bounds: Vec<Option<(f64, f64)>>,
pub right_segment_bounds: Vec<(f64, f64)>,
pub available_cols: usize,
pub correct_scroll_offset: usize,
}
impl TabBar {
pub fn layout<F1, F2>(
&self,
bar_width: f32,
bar_height: f32,
scroll_arrow_width: f32,
measure_tab: F1,
measure_segment: F2,
) -> TabBarLayout
where
F1: Fn(usize) -> TabMeasure,
F2: Fn(usize) -> SegmentMeasure,
{
let mut visible_tabs: Vec<VisibleTab> = Vec::new();
let mut visible_segments: Vec<VisibleSegment> = Vec::new();
let mut hit_regions: Vec<(Rect, TabBarHit)> = Vec::new();
if self.tabs.is_empty() && self.right_segments.is_empty() {
return TabBarLayout {
bar_width,
bar_height,
visible_tabs,
visible_segments,
scroll_left: None,
scroll_right: None,
hit_regions,
resolved_scroll_offset: 0,
};
}
let seg_widths: Vec<f32> = (0..self.right_segments.len())
.map(|i| measure_segment(i).width)
.collect();
let total_seg_width: f32 = seg_widths.iter().sum();
let segs_fit = !self.right_segments.is_empty() && total_seg_width <= bar_width;
let right_area_width = if segs_fit { total_seg_width } else { 0.0 };
let tab_measures: Vec<TabMeasure> = (0..self.tabs.len()).map(&measure_tab).collect();
let total_tab_width: f32 = tab_measures.iter().map(|m| m.total_width).sum();
let tab_area_no_scroll = (bar_width - right_area_width).max(0.0);
let active_idx = self.tabs.iter().position(|t| t.is_active).unwrap_or(0);
let (resolved_scroll_offset, tab_start_x, tab_end_x, needs_left, needs_right) =
if self.tabs.is_empty() {
(0usize, 0.0, tab_area_no_scroll, false, false)
} else if total_tab_width <= tab_area_no_scroll + f32::EPSILON {
(0usize, 0.0, tab_area_no_scroll, false, false)
} else if scroll_arrow_width <= 0.0 {
let offset = self.scroll_offset.min(self.tabs.len().saturating_sub(1));
(offset, 0.0, tab_area_no_scroll, false, false)
} else {
let tab_area_with_scroll = (tab_area_no_scroll - 2.0 * scroll_arrow_width).max(0.0);
let avail_usize = tab_area_with_scroll as usize;
let scroll_offset =
Self::fit_active_scroll_offset(active_idx, self.tabs.len(), avail_usize, |i| {
tab_measures[i].total_width.ceil() as usize
});
let sum_from_offset: f32 = tab_measures[scroll_offset..]
.iter()
.map(|m| m.total_width)
.sum();
let needs_right = sum_from_offset > tab_area_with_scroll + f32::EPSILON;
let needs_left = scroll_offset > 0;
let tab_start = scroll_arrow_width;
(
scroll_offset,
tab_start,
tab_start + tab_area_with_scroll,
needs_left,
needs_right,
)
};
let scroll_left = if needs_left {
let r = Rect::new(0.0, 0.0, scroll_arrow_width, bar_height);
hit_regions.push((r, TabBarHit::ScrollLeft));
Some(r)
} else {
None
};
let mut close_regions: Vec<(Rect, TabBarHit)> = Vec::new();
let mut body_regions: Vec<(Rect, TabBarHit)> = Vec::new();
let mut cursor_x = tab_start_x;
for (i, tm) in tab_measures.iter().enumerate().skip(resolved_scroll_offset) {
let tm = *tm;
if cursor_x + tm.total_width > tab_end_x + f32::EPSILON {
break;
}
let bounds = Rect::new(cursor_x, 0.0, tm.total_width, bar_height);
let close_bounds =
if tm.close_width > 0.0 && tm.close_width + tm.trailing_width <= tm.total_width {
Some(Rect::new(
cursor_x + tm.total_width - tm.trailing_width - tm.close_width,
0.0,
tm.close_width,
bar_height,
))
} else {
None
};
visible_tabs.push(VisibleTab {
tab_idx: i,
bounds,
close_bounds,
});
if let Some(cb) = close_bounds {
close_regions.push((cb, TabBarHit::TabClose(i)));
}
body_regions.push((bounds, TabBarHit::Tab(i)));
cursor_x += tm.total_width;
}
hit_regions.extend(close_regions);
hit_regions.extend(body_regions);
let scroll_right = if needs_right {
let r = Rect::new(tab_end_x, 0.0, scroll_arrow_width, bar_height);
hit_regions.push((r, TabBarHit::ScrollRight));
Some(r)
} else {
None
};
if segs_fit {
let mut seg_x = bar_width - right_area_width;
for (i, seg) in self.right_segments.iter().enumerate() {
let w = seg_widths[i];
let bounds = Rect::new(seg_x, 0.0, w, bar_height);
let clickable = seg.id.is_some();
visible_segments.push(VisibleSegment {
segment_idx: i,
bounds,
clickable,
});
if let Some(id) = &seg.id {
hit_regions.push((bounds, TabBarHit::RightSegment(*id)));
}
seg_x += w;
}
}
TabBarLayout {
bar_width,
bar_height,
visible_tabs,
visible_segments,
scroll_left,
scroll_right,
hit_regions,
resolved_scroll_offset,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TabBarEvent {
TabActivated { index: usize },
TabClosed { index: usize },
ButtonClicked { id: WidgetId },
KeyPressed { key: String, modifiers: Modifiers },
}
#[cfg(test)]
mod hit_test_diff_tests {
use super::*;
fn make_diff_bar() -> TabBar {
TabBar {
id: WidgetId::new("tabs:group"),
tabs: vec![
TabItem {
label: "main.rs".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "lib.rs".into(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: true,
},
],
right_segments: vec![
TabBarSegment {
id: None,
text: " 1 of 5".into(),
width_cells: 7,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:diff_prev")),
text: " a".into(),
width_cells: 3,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:diff_next")),
text: " b".into(),
width_cells: 3,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:diff_toggle")),
text: " c".into(),
width_cells: 3,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:split_right")),
text: " d".into(),
width_cells: 3,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:split_down")),
text: " e ".into(),
width_cells: 3,
is_active: false,
},
TabBarSegment {
id: Some(WidgetId::new("tab:action_menu")),
text: " f ".into(),
width_cells: 3,
is_active: false,
},
],
active_accent: None,
scroll_offset: 0,
show_tab_close: true,
compact: false,
}
}
#[test]
fn diff_buttons_resolve_to_correct_widget_ids() {
let bar = make_diff_bar();
let bar_width = 80.0_f32;
let tab_widths = [9_usize, 8];
let layout = bar.layout(
bar_width,
1.0,
0.0,
|i| TabMeasure::new(tab_widths[i] as f32, 2.0),
|i| SegmentMeasure::new(bar.right_segments[i].width_cells as f32),
);
for (col, expected) in [
(62, "tab:diff_prev"),
(64, "tab:diff_prev"),
(65, "tab:diff_next"),
(67, "tab:diff_next"),
(68, "tab:diff_toggle"),
(70, "tab:diff_toggle"),
(71, "tab:split_right"),
(74, "tab:split_down"),
(77, "tab:action_menu"),
] {
let hit = layout.hit_test(col as f32, 0.0);
match hit {
TabBarHit::RightSegment(id) => {
assert_eq!(
id.as_str(),
expected,
"click at col {col} expected {expected}, got {id:?}"
);
}
other => {
panic!("click at col {col} expected RightSegment({expected}), got {other:?}")
}
}
}
match layout.hit_test(56.0, 0.0) {
TabBarHit::Empty => {}
other => panic!("click at col 56 (change_label) expected Empty, got {other:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_bar_roundtrip_serde() {
let bar = TabBar {
id: WidgetId::new("group-0-tabs"),
tabs: vec![
TabItem {
label: " 1: main.rs ".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: " 2: lib.rs ".to_string(),
is_active: false,
is_dirty: true,
is_preview: false,
is_closable: true,
},
TabItem {
label: " 3: render.rs ".to_string(),
is_active: false,
is_dirty: false,
is_preview: true,
is_closable: true,
},
],
scroll_offset: 0,
right_segments: vec![
TabBarSegment {
text: "2 of 5 ".to_string(),
width_cells: 7,
id: None,
is_active: false,
},
TabBarSegment {
text: " ← ".to_string(),
width_cells: 3,
id: Some(WidgetId::new("tab:diff_prev")),
is_active: false,
},
TabBarSegment {
text: " … ".to_string(),
width_cells: 3,
id: Some(WidgetId::new("tab:action_menu")),
is_active: false,
},
],
active_accent: Some(Color::rgb(100, 200, 255)),
show_tab_close: true,
compact: false,
};
let json = serde_json::to_string(&bar).unwrap();
let back: TabBar = serde_json::from_str(&json).unwrap();
assert_eq!(bar, back);
}
#[test]
fn tab_bar_fit_active_scroll_offset() {
let measure = |_i: usize| 100usize;
assert_eq!(TabBar::fit_active_scroll_offset(0, 10, 250, measure), 0);
assert_eq!(TabBar::fit_active_scroll_offset(1, 10, 250, measure), 0);
assert_eq!(TabBar::fit_active_scroll_offset(2, 10, 250, measure), 1);
assert_eq!(TabBar::fit_active_scroll_offset(9, 10, 250, measure), 8);
let varied = [60, 80, 70, 90, 100, 50, 120, 75, 65, 85];
let measure_varied = |i: usize| varied[i];
assert_eq!(
TabBar::fit_active_scroll_offset(9, 10, 200, measure_varied),
8
);
assert_eq!(
TabBar::fit_active_scroll_offset(5, 10, 30, measure_varied),
5
);
assert_eq!(TabBar::fit_active_scroll_offset(0, 0, 100, measure), 0);
assert_eq!(TabBar::fit_active_scroll_offset(99, 5, 100, measure), 0);
}
fn make_tab(label: &str, is_active: bool) -> TabItem {
TabItem {
label: label.to_string(),
is_active,
is_dirty: false,
is_preview: false,
is_closable: true,
}
}
fn make_bar(tabs: Vec<TabItem>) -> TabBar {
TabBar {
id: WidgetId::new("t"),
tabs,
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
}
}
#[test]
fn tab_bar_layout_empty() {
let bar = make_bar(vec![]);
let layout = bar.layout(
100.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 0);
assert_eq!(layout.visible_segments.len(), 0);
assert!(layout.scroll_left.is_none());
assert!(layout.scroll_right.is_none());
assert_eq!(layout.hit_regions.len(), 0);
assert_eq!(layout.resolved_scroll_offset, 0);
assert_eq!(layout.hit_test(5.0, 1.0), TabBarHit::Empty);
}
#[test]
fn tab_bar_layout_single_tab_fits() {
let bar = make_bar(vec![make_tab("main.rs", true)]);
let layout = bar.layout(
20.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 2.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 1);
assert_eq!(layout.visible_tabs[0].tab_idx, 0);
assert_eq!(layout.visible_tabs[0].bounds.x, 0.0);
assert_eq!(layout.visible_tabs[0].bounds.width, 10.0);
assert!(layout.visible_tabs[0].close_bounds.is_some());
let cb = layout.visible_tabs[0].close_bounds.unwrap();
assert_eq!(cb.x, 8.0); assert_eq!(cb.width, 2.0);
assert!(layout.scroll_left.is_none());
assert!(layout.scroll_right.is_none());
assert_eq!(layout.hit_test(5.0, 1.0), TabBarHit::Tab(0));
assert_eq!(layout.hit_test(9.0, 1.0), TabBarHit::TabClose(0));
assert_eq!(layout.hit_test(15.0, 1.0), TabBarHit::Empty);
}
#[test]
fn tab_bar_layout_tui_char_units() {
let bar = make_bar(vec![
make_tab("a", true),
make_tab("b", false),
make_tab("c", false),
]);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 3);
assert_eq!(layout.visible_tabs[0].bounds.x, 0.0);
assert_eq!(layout.visible_tabs[1].bounds.x, 10.0);
assert_eq!(layout.visible_tabs[2].bounds.x, 20.0);
assert!(layout.scroll_left.is_none());
assert!(layout.scroll_right.is_none());
assert_eq!(layout.resolved_scroll_offset, 0);
}
#[test]
fn tab_bar_layout_pixel_units_fractional() {
let bar = make_bar(vec![
make_tab("a", true),
make_tab("b", false),
make_tab("c", false),
]);
let layout = bar.layout(
400.0,
22.0,
16.0,
|_| TabMeasure::new(87.5, 18.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 3);
assert_eq!(layout.visible_tabs[0].bounds.x, 0.0);
assert_eq!(layout.visible_tabs[1].bounds.x, 87.5);
assert_eq!(layout.visible_tabs[2].bounds.x, 175.0);
assert_eq!(layout.visible_tabs[0].bounds.height, 22.0);
let cb = layout.visible_tabs[0].close_bounds.unwrap();
assert_eq!(cb.x, 87.5 - 18.0);
assert_eq!(cb.width, 18.0);
}
#[test]
fn tab_bar_layout_overflow_active_in_middle() {
let bar = make_bar(
(0..10)
.map(|i| make_tab(&format!("t{i}"), i == 5))
.collect(),
);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert!(layout.scroll_left.is_some());
assert!(layout.scroll_right.is_some());
let visible_indices: Vec<usize> = layout.visible_tabs.iter().map(|v| v.tab_idx).collect();
assert!(
visible_indices.contains(&5),
"active tab 5 not visible: {:?}",
visible_indices
);
assert!(layout.resolved_scroll_offset > 0);
assert_eq!(layout.visible_tabs[0].bounds.x, 2.0);
}
#[test]
fn tab_bar_layout_overflow_active_at_start() {
let bar = make_bar(
(0..10)
.map(|i| make_tab(&format!("t{i}"), i == 0))
.collect(),
);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert!(layout.scroll_left.is_none()); assert!(layout.scroll_right.is_some()); assert_eq!(layout.resolved_scroll_offset, 0);
}
#[test]
fn tab_bar_layout_overflow_active_at_end() {
let bar = make_bar(
(0..10)
.map(|i| make_tab(&format!("t{i}"), i == 9))
.collect(),
);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert!(layout.scroll_left.is_some());
assert!(layout.scroll_right.is_none()); assert!(layout.resolved_scroll_offset > 0);
assert!(layout.visible_tabs.iter().any(|v| v.tab_idx == 9));
}
#[test]
fn tab_bar_layout_right_segments_fit() {
let bar = TabBar {
id: WidgetId::new("t"),
tabs: vec![make_tab("a", true)],
scroll_offset: 0,
right_segments: vec![
TabBarSegment {
text: " ← ".to_string(),
width_cells: 3,
id: Some(WidgetId::new("prev")),
is_active: false,
},
TabBarSegment {
text: " → ".to_string(),
width_cells: 3,
id: Some(WidgetId::new("next")),
is_active: false,
},
],
active_accent: None,
show_tab_close: true,
compact: false,
};
let layout = bar.layout(
100.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|i| {
let w = [3.0, 3.0][i];
SegmentMeasure::new(w)
},
);
assert_eq!(layout.visible_segments.len(), 2);
assert_eq!(layout.visible_segments[0].bounds.x, 94.0);
assert_eq!(layout.visible_segments[1].bounds.x, 97.0);
let hit = layout.hit_test(95.0, 1.0);
match hit {
TabBarHit::RightSegment(id) => assert_eq!(id.as_str(), "prev"),
other => panic!("expected RightSegment(prev), got {other:?}"),
}
}
#[test]
fn tab_bar_layout_right_segments_dropped_when_too_wide() {
let bar = TabBar {
id: WidgetId::new("t"),
tabs: vec![make_tab("a", true)],
scroll_offset: 0,
right_segments: vec![TabBarSegment {
text: "many many pixels".to_string(),
width_cells: 60,
id: Some(WidgetId::new("huge")),
is_active: false,
}],
active_accent: None,
show_tab_close: true,
compact: false,
};
let layout = bar.layout(
50.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(60.0),
);
assert_eq!(layout.visible_segments.len(), 0);
for (_, hit) in &layout.hit_regions {
assert!(!matches!(hit, TabBarHit::RightSegment(_)));
}
let bar2 = bar.clone();
let layout2 = bar2.layout(
100.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(60.0),
);
assert_eq!(layout2.visible_segments.len(), 1);
}
#[test]
fn tab_bar_layout_stale_scroll_offset_corrected() {
let tabs: Vec<_> = (0..10)
.map(|i| make_tab(&format!("t{i}"), i == 9))
.collect();
let mut bar = make_bar(tabs);
bar.scroll_offset = 0; let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert!(
layout.resolved_scroll_offset > 0,
"expected correction but got {}",
layout.resolved_scroll_offset
);
}
#[test]
fn tab_bar_layout_no_arrows_honours_caller_scroll_offset() {
let tabs: Vec<_> = (0..10)
.map(|i| make_tab(&format!("t{i}"), i == 9))
.collect();
let mut bar = make_bar(tabs);
bar.scroll_offset = 7;
let layout = bar.layout(
30.0,
1.0,
0.0, |_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.resolved_scroll_offset, 7);
assert_eq!(layout.visible_tabs.len(), 3);
assert_eq!(layout.visible_tabs[0].tab_idx, 7);
assert_eq!(layout.visible_tabs[2].tab_idx, 9);
}
#[test]
fn tab_bar_layout_no_arrows_clamps_oversize_scroll_offset() {
let tabs: Vec<_> = (0..3).map(|i| make_tab(&format!("t{i}"), false)).collect();
let mut bar = make_bar(tabs);
bar.scroll_offset = 99;
let layout = bar.layout(
30.0,
1.0,
0.0,
|_| TabMeasure::new(50.0, 1.0), |_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.resolved_scroll_offset, 2); }
#[test]
fn tab_bar_layout_no_close_button_when_close_width_zero() {
let bar = make_bar(vec![make_tab("pinned.rs", true)]);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 0.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 1);
assert!(layout.visible_tabs[0].close_bounds.is_none());
assert_eq!(layout.hit_test(5.0, 0.5), TabBarHit::Tab(0));
assert_eq!(layout.hit_test(9.5, 0.5), TabBarHit::Tab(0));
}
#[test]
fn tab_bar_layout_hit_test_outside_bar() {
let bar = make_bar(vec![make_tab("a", true)]);
let layout = bar.layout(
20.0,
2.0,
2.0,
|_| TabMeasure::new(10.0, 2.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.hit_test(100.0, 1.0), TabBarHit::Empty);
assert_eq!(layout.hit_test(5.0, 100.0), TabBarHit::Empty);
assert_eq!(layout.hit_test(-1.0, 1.0), TabBarHit::Empty);
}
#[test]
fn tab_bar_layout_scroll_disabled_tabs_clip() {
let bar = make_bar(
(0..10)
.map(|i| make_tab(&format!("t{i}"), i == 0))
.collect(),
);
let layout = bar.layout(
30.0,
1.0,
0.0, |_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.visible_tabs.len(), 3);
assert!(layout.scroll_left.is_none());
assert!(layout.scroll_right.is_none());
}
#[test]
fn tab_bar_layout_tab_center_and_tab_close_center() {
let bar = make_bar(vec![
make_tab("a", true),
make_tab("b", false),
make_tab("c", false),
]);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 2.0),
|_| SegmentMeasure::new(0.0),
);
assert_eq!(layout.tab_center(1), Some((15.0, 0.5)));
assert_eq!(layout.tab_close_center(1), Some((19.0, 0.5)));
assert_eq!(layout.tab_center(99), None);
assert_eq!(layout.tab_close_center(99), None);
}
#[test]
fn tab_bar_layout_tab_center_none_when_scrolled_out_of_view() {
let bar = make_bar(
(0..10)
.map(|i| make_tab(&format!("t{i}"), i == 9))
.collect(),
);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 1.0),
|_| SegmentMeasure::new(0.0),
);
assert!(
!layout.visible_tabs.iter().any(|v| v.tab_idx == 0),
"tab 0 should have scrolled out of view"
);
assert_eq!(
layout.tab_center(0),
None,
"tab_center must be None for a tab hidden behind scroll_offset"
);
assert_eq!(layout.tab_close_center(0), None);
}
#[test]
fn tab_bar_layout_tab_close_center_none_when_not_closable() {
let bar = make_bar(vec![make_tab("pinned.rs", true)]);
let layout = bar.layout(
30.0,
1.0,
2.0,
|_| TabMeasure::new(10.0, 0.0),
|_| SegmentMeasure::new(0.0),
);
assert!(layout.tab_center(0).is_some());
assert_eq!(
layout.tab_close_center(0),
None,
"tab_close_center must be None when the tab drew no close button"
);
}
#[test]
fn tab_bar_event_roundtrip_serde() {
let events = vec![
TabBarEvent::TabActivated { index: 2 },
TabBarEvent::TabClosed { index: 0 },
TabBarEvent::ButtonClicked {
id: WidgetId::new("tab:split_right"),
},
TabBarEvent::KeyPressed {
key: "F1".to_string(),
modifiers: Modifiers::default(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: TabBarEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
}