use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use super::text::char_cell_width;
use super::{ratatui_color, set_cell, set_cell_styled, set_cell_wide, set_cell_wide_styled};
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits, TabBarLayout, TabChrome, TabFrame};
use crate::theme::Theme;
use crate::types::Icon;
fn tab_glyph_char(icon: Option<&Icon>, nerd_fonts_enabled: bool, default: char) -> char {
icon.map(|icon| icon.resolve(nerd_fonts_enabled))
.and_then(|s| s.chars().next())
.unwrap_or(default)
}
pub const TAB_CLOSE_CHAR: char = '×';
pub const TAB_CLOSE_COLS: u16 = 2;
pub const TAB_SEPARATOR_CHAR: char = '│';
pub const TAB_SEPARATOR_COLS: u16 = 1;
#[allow(deprecated)] pub fn draw_tab_bar(
buf: &mut Buffer,
area: Rect,
bar: &TabBar,
layout: &TabBarLayout,
theme: &Theme,
) -> TabBarHits {
draw_tab_bar_icons(buf, area, bar, &[], layout, theme)
}
#[allow(deprecated)] pub fn draw_tab_bar_icons(
buf: &mut Buffer,
area: Rect,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
layout: &TabBarLayout,
theme: &Theme,
) -> TabBarHits {
draw_tab_bar_icons_with_chrome(buf, area, bar, icons, &TabChrome::default(), layout, theme)
}
#[allow(deprecated)] pub fn draw_tab_bar_with_chrome(
buf: &mut Buffer,
area: Rect,
bar: &TabBar,
chrome: &TabChrome,
layout: &TabBarLayout,
theme: &Theme,
) -> TabBarHits {
draw_tab_bar_icons_with_chrome(buf, area, bar, &[], chrome, layout, theme)
}
#[allow(deprecated)] pub fn draw_tab_bar_icons_with_chrome(
buf: &mut Buffer,
area: Rect,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
chrome: &TabChrome,
layout: &TabBarLayout,
theme: &Theme,
) -> TabBarHits {
draw_tab_bar_icons_with_chrome_and_glyphs(
buf, area, bar, icons, chrome, layout, theme, None, false,
)
}
#[allow(deprecated)] #[allow(clippy::too_many_arguments)]
pub fn draw_tab_bar_icons_with_chrome_and_glyphs(
buf: &mut Buffer,
area: Rect,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
chrome: &TabChrome,
layout: &TabBarLayout,
theme: &Theme,
glyphs: Option<&crate::primitives::tab_bar::TabBarGlyphs>,
nerd_fonts_enabled: bool,
) -> TabBarHits {
if area.width == 0 || area.height == 0 {
return TabBarHits::default();
}
let bar_bg = ratatui_color(theme.tab_bar_bg);
let btn_fg = ratatui_color(theme.tab_inactive_fg);
let btn_fg_active = ratatui_color(theme.tab_active_fg);
let foreground = ratatui_color(theme.foreground);
for x in area.x..area.x + area.width {
set_cell(buf, x, area.y, ' ', bar_bg, bar_bg);
}
let reserved: u16 = bar.right_segments.iter().map(|s| s.width_cells).sum();
let tab_content_width = if area.width >= reserved {
(area.width - reserved) as usize
} else {
area.width as usize
};
let mut right_segment_bounds: Vec<(f64, f64)> = Vec::with_capacity(bar.right_segments.len());
for vs in &layout.visible_segments {
let seg = &bar.right_segments[vs.segment_idx];
let fg = if seg.is_active { btn_fg_active } else { btn_fg };
let bx = area.x + vs.bounds.x.round() as u16;
let seg_end = bx + seg.width_cells;
right_segment_bounds.push((bx as f64, seg_end as f64));
let mut cx = bx;
for ch in seg.text.chars() {
if cx >= seg_end {
break;
}
let w = char_cell_width(ch);
if w == 2 {
if cx + 1 < seg_end + 1 {
set_cell_wide(buf, cx, area.y, ch, fg, bar_bg);
cx += 2;
} else {
cx += 1;
}
} else {
set_cell(buf, cx, area.y, ch, fg, bar_bg);
cx += 1;
}
}
}
let accent = bar.active_accent.map(ratatui_color);
let active_fg = ratatui_color(theme.tab_active_fg);
let active_bg = ratatui_color(theme.tab_active_bg);
let preview_active_fg = ratatui_color(theme.tab_preview_active_fg);
let inactive_fg = ratatui_color(theme.tab_inactive_fg);
let preview_inactive_fg = ratatui_color(theme.tab_preview_inactive_fg);
let separator = ratatui_color(theme.separator);
let mut slot_positions: Vec<(f64, f64)> = Vec::with_capacity(bar.tabs.len());
let mut close_bounds: Vec<Option<(f64, f64)>> = Vec::with_capacity(bar.tabs.len());
for _ in 0..bar.scroll_offset.min(bar.tabs.len()) {
slot_positions.push((0.0, 0.0));
close_bounds.push(None);
}
for vt in &layout.visible_tabs {
let tab = &bar.tabs[vt.tab_idx];
let tab_x = area.x + vt.bounds.x.round() as u16;
let tab_w = vt.bounds.width.round() as u16;
slot_positions.push((tab_x as f64, (tab_x + tab_w) as f64));
close_bounds.push(vt.close_bounds.map(|cb| {
let cx = area.x as f64 + cb.x as f64;
(cx, cx + cb.width as f64)
}));
let (fg, bg) = match (tab.is_active, tab.is_preview) {
(true, true) => (preview_active_fg, active_bg),
(true, false) => (active_fg, active_bg),
(false, true) => (preview_inactive_fg, bar_bg),
(false, false) => (inactive_fg, bar_bg),
};
let bracket_active = matches!(chrome.active_frame, TabFrame::Brackets) && tab.is_active;
let lead: u16 = if bracket_active { 1 } else { 0 };
if bracket_active {
set_cell(buf, tab_x, area.y, '[', fg, bg);
}
let icon_cols = crate::primitives::tab_bar::tab_icon_cols(icons, vt.tab_idx);
if let Some(icon) = crate::primitives::tab_bar::tab_icon_at(icons, vt.tab_idx) {
let icon_fg = ratatui_color(icon.color);
let icon_end = tab_x + lead + icon_cols;
let mut cx = tab_x + lead;
for ch in icon.glyph.chars() {
if cx >= icon_end {
break;
}
let w = char_cell_width(ch);
if w == 2 {
set_cell_wide(buf, cx, area.y, ch, icon_fg, bg);
cx += 2;
} else {
set_cell(buf, cx, area.y, ch, icon_fg, bg);
cx += 1;
}
}
}
let mut modifier = Modifier::empty();
if tab.is_preview {
modifier |= Modifier::ITALIC;
}
if tab.is_active && accent.is_some() {
modifier |= Modifier::UNDERLINED;
}
let prefix_mod = if tab.is_preview {
Modifier::ITALIC
} else {
Modifier::empty()
};
let tab_width = vt.bounds.width.round() as u16;
let trailing_bracket_no_close: u16 = if bracket_active && vt.close_bounds.is_none() {
1
} else {
0
};
let label_end = match vt.close_bounds {
Some(cb) => area.x + cb.x.round() as u16,
None => tab_x + tab_width - trailing_bracket_no_close,
};
let tab_end = tab_x + tab_width;
let prefix_len = tab.label.rfind(": ").map(|p| p + 2).unwrap_or(0);
let mut x = tab_x + lead + icon_cols;
for (ci, ch) in tab.label.chars().enumerate() {
if x >= label_end {
break;
}
let in_filename = ci >= prefix_len;
let cell_mod = if in_filename { modifier } else { prefix_mod };
let ul = if in_filename && tab.is_active {
accent
} else {
None
};
let w = char_cell_width(ch);
if w == 2 {
if x + 1 < label_end + 1 {
set_cell_wide_styled(buf, x, area.y, ch, fg, bg, cell_mod, ul);
x += 2;
} else {
x += 1;
}
} else {
set_cell_styled(buf, x, area.y, ch, fg, bg, cell_mod, ul);
x += 1;
}
}
if vt.close_bounds.is_some() && x < tab_end {
let (close_ch, close_fg) = if tab.is_dirty {
let ch = tab_glyph_char(
glyphs.and_then(crate::primitives::tab_bar::TabBarGlyphs::dirty_glyph),
nerd_fonts_enabled,
'●',
);
(ch, foreground)
} else {
let ch = tab_glyph_char(
glyphs.and_then(crate::primitives::tab_bar::TabBarGlyphs::close_glyph),
nerd_fonts_enabled,
TAB_CLOSE_CHAR,
);
let fg = if tab.is_active { active_fg } else { separator };
(ch, fg)
};
set_cell(buf, x, area.y, close_ch, close_fg, bg);
x += 1;
}
if bracket_active {
if x < tab_end {
set_cell(buf, x, area.y, ']', fg, bg);
}
} else if x < tab_end {
if vt.close_bounds.is_none() {
set_cell(buf, x, area.y, TAB_SEPARATOR_CHAR, separator, bar_bg);
} else {
set_cell(buf, x, area.y, ' ', bar_bg, bar_bg);
}
}
}
TabBarHits {
slot_positions,
close_bounds,
right_segment_bounds,
available_cols: tab_content_width,
correct_scroll_offset: bar.scroll_offset,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::tab_bar::{SegmentMeasure, TabBar, TabBarSegment, TabItem, TabMeasure};
use crate::types::WidgetId;
fn make_bar(active_idx: usize) -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "main.rs".into(),
is_active: active_idx == 0,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "lib.rs".into(),
is_active: active_idx == 1,
is_dirty: true,
is_preview: false,
is_closable: true,
},
],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: true,
compact: false,
}
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
fn measure_tab(_idx: usize) -> TabMeasure {
TabMeasure::new(12.0, 1.0)
}
#[test]
fn paints_two_tabs_with_close_glyph() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
let bar = make_bar(0);
let layout = bar.layout(40.0, 1.0, 0.0, measure_tab, |_| SegmentMeasure::new(0.0));
draw_tab_bar(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&layout,
&Theme::default(),
);
let mut found_x_close = false;
let mut found_dirty_dot = false;
for x in 0..40 {
match cell_char(&buf, x, 0) {
'×' => found_x_close = true,
'●' => found_dirty_dot = true,
_ => {}
}
}
assert!(found_x_close, "expected '×' close glyph somewhere");
assert!(found_dirty_dot, "expected '●' dirty glyph somewhere");
}
#[test]
fn tab_bar_glyphs_override_swaps_close_and_dirty_default_keeps_defaults() {
use crate::primitives::tab_bar::TabBarGlyphs;
let bar = make_bar(0);
let layout = bar.layout(40.0, 1.0, 0.0, measure_tab, |_| SegmentMeasure::new(0.0));
let glyphs = TabBarGlyphs::new()
.with_close_glyph("x")
.with_dirty_glyph("*");
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_tab_bar_icons_with_chrome_and_glyphs(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&[],
&TabChrome::default(),
&layout,
&Theme::default(),
Some(&glyphs),
false,
);
let mut found_override_close = false;
let mut found_override_dirty = false;
let mut found_default_close = false;
let mut found_default_dirty = false;
for x in 0..40 {
match cell_char(&buf, x, 0) {
'x' => found_override_close = true,
'*' => found_override_dirty = true,
'×' => found_default_close = true,
'●' => found_default_dirty = true,
_ => {}
}
}
assert!(found_override_close, "expected overridden close glyph 'x'");
assert!(found_override_dirty, "expected overridden dirty glyph '*'");
assert!(!found_default_close, "default '×' should not appear");
assert!(!found_default_dirty, "default '●' should not appear");
let mut default_buf = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_tab_bar_icons_with_chrome_and_glyphs(
&mut default_buf,
Rect::new(0, 0, 40, 1),
&bar,
&[],
&TabChrome::default(),
&layout,
&Theme::default(),
None,
false,
);
let mut default_found_close = false;
let mut default_found_dirty = false;
for x in 0..40 {
match cell_char(&default_buf, x, 0) {
'×' => default_found_close = true,
'●' => default_found_dirty = true,
_ => {}
}
}
assert!(default_found_close, "expected default '×' close glyph");
assert!(default_found_dirty, "expected default '●' dirty glyph");
}
#[test]
#[allow(deprecated)] fn returns_full_width_when_no_right_segments() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1));
let bar = make_bar(0);
let layout = bar.layout(30.0, 1.0, 0.0, measure_tab, |_| SegmentMeasure::new(0.0));
let hits = draw_tab_bar(
&mut buf,
Rect::new(0, 0, 30, 1),
&bar,
&layout,
&Theme::default(),
);
assert_eq!(hits.available_cols, 30);
}
#[test]
#[allow(deprecated)] fn reserves_right_segment_width() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 1));
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: "x".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
}],
right_segments: vec![TabBarSegment {
id: Some(WidgetId::new("seg:0")),
text: "[+]".into(),
width_cells: 3,
is_active: false,
}],
active_accent: None,
scroll_offset: 0,
show_tab_close: true,
compact: false,
};
let layout = bar.layout(
30.0,
1.0,
0.0,
|_| TabMeasure::new(5.0, 0.0),
|_| SegmentMeasure::new(3.0),
);
let hits = draw_tab_bar(
&mut buf,
Rect::new(0, 0, 30, 1),
&bar,
&layout,
&Theme::default(),
);
assert_eq!(hits.available_cols, 27);
assert_eq!(hits.right_segment_bounds.len(), 1);
let (start, end) = hits.right_segment_bounds[0];
assert_eq!((start, end), (27.0, 30.0));
}
#[test]
fn wide_glyph_label_paints_every_char_across_its_own_columns() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
let label = "日本語.rs";
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: label.into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: false,
}],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: false,
compact: false,
};
let width = crate::tui::display_width(label) as f32;
assert_eq!(width, 9.0);
let layout = bar.layout(
40.0,
1.0,
0.0,
|_| TabMeasure::new(width, 0.0),
|_| SegmentMeasure::new(0.0),
);
draw_tab_bar(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&layout,
&Theme::default(),
);
let mut rendered = String::new();
let mut x = 0u16;
while x < 40 {
let sym = buf[(x, 0)].symbol();
if sym.is_empty() {
x += 1;
continue;
}
rendered.push_str(sym);
let w = sym.chars().next().map(char_cell_width).unwrap_or(1).max(1);
x += w;
}
assert!(
rendered.starts_with(label),
"expected {label:?} to paint intact (no dropped glyphs), got {rendered:?}"
);
}
#[test]
#[allow(deprecated)] fn zero_size_is_a_no_op() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
let bar = make_bar(0);
let layout = bar.layout(10.0, 1.0, 0.0, measure_tab, |_| SegmentMeasure::new(0.0));
let hits = draw_tab_bar(
&mut buf,
Rect::new(0, 0, 0, 1),
&bar,
&layout,
&Theme::default(),
);
assert_eq!(hits.available_cols, 0);
assert!(hits.slot_positions.is_empty());
}
#[test]
fn icon_glyph_paints_before_label_and_widens_measured_tab() {
use crate::primitives::tab_bar::TabIcon;
use crate::types::Color;
let icon = TabIcon {
glyph: "\u{f09b}".into(), color: Color::rgb(240, 150, 60),
};
let icons = vec![Some(icon.clone())];
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: "main.rs".into(),
is_active: true,
is_closable: false,
..Default::default()
}],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: false,
compact: false,
};
let icon_cols = crate::tab_icon_cols(&icons, 0);
assert_eq!(
icon_cols, 2,
"1-column glyph + 1-column gap before the label"
);
let label_w = crate::text_util::display_width(&bar.tabs[0].label) as f32;
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
let layout = bar.layout(
40.0,
1.0,
0.0,
|_| TabMeasure::new(icon_cols as f32 + label_w, 0.0),
|_| SegmentMeasure::new(0.0),
);
draw_tab_bar_icons(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&icons,
&layout,
&Theme::default(),
);
assert_eq!(
cell_char(&buf, 0, 0),
'\u{f09b}',
"icon glyph should paint at the tab's leading column"
);
assert_eq!(
buf[(0, 0)].fg,
ratatui_color(icon.color),
"icon glyph should paint in TabIcon::color"
);
assert_eq!(
cell_char(&buf, icon_cols, 0),
'm',
"label should start right after icon_cols (glyph + 1-column gap)"
);
}
#[test]
#[allow(deprecated)] fn empty_icon_sidecar_paints_identically_to_draw_tab_bar() {
let bar = make_bar(0);
let area = Rect::new(0, 0, 40, 1);
let layout = bar.layout(40.0, 1.0, 0.0, measure_tab, |_| SegmentMeasure::new(0.0));
let mut plain = Buffer::empty(area);
let plain_hits = draw_tab_bar(&mut plain, area, &bar, &layout, &Theme::default());
let mut sidecar = Buffer::empty(area);
let sidecar_hits =
draw_tab_bar_icons(&mut sidecar, area, &bar, &[], &layout, &Theme::default());
assert_eq!(plain, sidecar, "empty sidecar must not change any cell");
assert_eq!(
plain_hits.slot_positions, sidecar_hits.slot_positions,
"empty sidecar must not move any tab slot"
);
assert_eq!(
plain_hits.close_bounds, sidecar_hits.close_bounds,
"empty sidecar must not move any close button"
);
}
#[test]
#[allow(deprecated)] fn bracket_frame_encloses_close_glyph_and_close_bounds_stay_on_the_glyph() {
use crate::primitives::tab_bar::{TabBarHit, TabChrome, TabFrame};
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "main.rs".into(),
is_active: true,
..Default::default()
},
TabItem {
label: "lib.rs".into(),
is_active: false,
..Default::default()
},
],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: true,
compact: false,
};
let chrome = TabChrome::new(TabFrame::Brackets);
let layout = bar.layout(
40.0,
1.0,
0.0,
|i| {
if i == 0 {
TabMeasure::new(10.0, 1.0).with_trailing(1.0)
} else {
TabMeasure::new(8.0, 2.0)
}
},
|_| SegmentMeasure::new(0.0),
);
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
let hits = draw_tab_bar_with_chrome(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&chrome,
&layout,
&Theme::default(),
);
assert_eq!(cell_char(&buf, 0, 0), '[', "opening bracket at tab start");
assert_eq!(cell_char(&buf, 1, 0), 'm', "label starts right after '['");
assert_eq!(cell_char(&buf, 8, 0), '×', "close glyph right after label");
assert_eq!(
cell_char(&buf, 9, 0),
']',
"closing bracket right after the close glyph"
);
assert_eq!(cell_char(&buf, 10, 0), 'l', "tab 1's label is unframed");
let (start, end) = hits.close_bounds[0].expect("tab 0 should report close bounds");
assert_eq!(
(start, end),
(8.0, 9.0),
"close_bounds must cover just the × cell, not the ']' beside it"
);
match layout.hit_test(8.5, 0.0) {
TabBarHit::TabClose(0) => {}
other => panic!("click on the × glyph expected TabClose(0), got {other:?}"),
}
match layout.hit_test(9.5, 0.0) {
TabBarHit::Tab(0) => {}
other => panic!("click on the ']' bracket expected Tab(0) (not close), got {other:?}"),
}
}
#[test]
fn no_close_tabs_paint_a_separator_between_labels() {
use crate::primitives::tab_bar::TabBarHit;
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "Problems".into(),
is_active: true,
is_closable: false,
..Default::default()
},
TabItem {
label: "Output".into(),
is_active: false,
is_closable: false,
..Default::default()
},
TabItem {
label: "Terminal".into(),
is_active: false,
is_closable: false,
..Default::default()
},
],
right_segments: vec![],
active_accent: None,
scroll_offset: 0,
show_tab_close: false,
compact: true,
};
let layout = bar.layout(
40.0,
1.0,
0.0,
|i| {
let label_w = crate::tui::display_width(&bar.tabs[i].label) as f32;
TabMeasure::new(label_w + TAB_SEPARATOR_COLS as f32, 0.0)
},
|_| SegmentMeasure::new(0.0),
);
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
draw_tab_bar(
&mut buf,
Rect::new(0, 0, 40, 1),
&bar,
&layout,
&Theme::default(),
);
assert_eq!(
cell_char(&buf, 8, 0),
TAB_SEPARATOR_CHAR,
"expected a separator glyph between the first and second tab"
);
assert_eq!(
cell_char(&buf, 15, 0),
TAB_SEPARATOR_CHAR,
"expected a separator glyph between the second and third tab"
);
assert_eq!(
layout.hit_test(8.5, 0.0),
TabBarHit::Tab(0),
"the separator column must stay part of the tab it follows, not a dead zone"
);
}
}