use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::pane::Pane;
use crate::ui::theme;
pub(crate) fn split_http_verb(name: &str) -> Option<(String, String)> {
for verb in &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {
if let Some(rest) = name.strip_prefix(verb) {
let rest = rest.trim_start();
if !rest.is_empty() {
return Some((verb.to_string(), rest.to_string()));
}
}
}
None
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DiagChipSeverity {
None,
Warning,
Error,
}
pub(crate) fn diag_chip_for(p: &Pane, style: &str) -> (String, DiagChipSeverity) {
if style == "off" {
return (String::new(), DiagChipSeverity::None);
}
if let Pane::Editor(b) = p {
let mut err = 0usize;
let mut warn = 0usize;
for d in b.all_diagnostics() {
match d.severity {
crate::lsp::Severity::Error => err += 1,
crate::lsp::Severity::Warning => warn += 1,
_ => {}
}
}
match style {
"dot" => {
if err > 0 {
return ("\u{25CF}".to_string(), DiagChipSeverity::Error);
}
if warn > 0 {
return ("\u{25CF}".to_string(), DiagChipSeverity::Warning);
}
}
_ => {
if err > 0 {
return (format!("\u{2717}{err}"), DiagChipSeverity::Error);
}
if warn > 0 {
return (format!("\u{26A0}{warn}"), DiagChipSeverity::Warning);
}
}
}
}
(String::new(), DiagChipSeverity::None)
}
#[derive(Clone)]
pub struct TabChipInputs {
pub id: crate::layout::PaneId,
pub glyph: String,
pub icon_color: ratatui::style::Color,
pub name: String,
pub is_active: bool,
pub is_dirty: bool,
pub is_pinned: bool,
pub is_preview: bool,
pub is_hovered: bool,
pub diag_chip: String,
pub diag_severity: DiagChipSeverity,
pub verb_split: Option<(String, String)>,
pub name_cap: usize,
}
pub struct TabChipRects {
pub chip: Rect,
pub close: Option<Rect>,
}
pub fn tab_chip_spans(
inputs: &TabChipInputs,
strip_bg: ratatui::style::Color,
avail_width: u16,
nerd: bool,
) -> Option<(Vec<Span<'static>>, u16)> {
if avail_width == 0 {
return None;
}
let t = theme::cur();
let pin_glyph = if nerd { "\u{f08d}" } else { "P" };
let close_glyph = if nerd { "\u{F0156}" } else { "x" };
let (badge, badge_fg_active, badge_fg_inactive) = if inputs.is_pinned {
(pin_glyph.to_string(), t.yellow, t.yellow)
} else if inputs.is_active {
(close_glyph.to_string(), t.red, t.grey)
} else if inputs.is_hovered && inputs.is_dirty {
(close_glyph.to_string(), t.orange, t.orange)
} else if inputs.is_hovered {
(close_glyph.to_string(), t.grey_fg, t.grey_fg)
} else if inputs.is_dirty {
("●".to_string(), t.orange, t.orange)
} else {
(" ".to_string(), t.grey_fg, t.grey)
};
let skip_icon = inputs.glyph.is_empty();
let (name_clipped, name_cells) = if let Some((_, rest)) = &inputs.verb_split {
let clipped = crate::ui::clip_to_cells(rest, inputs.name_cap);
let cells = clipped.chars().count() as u16;
(clipped, cells)
} else {
let clipped = crate::ui::clip_to_cells(&inputs.name, inputs.name_cap);
let cells = clipped.chars().count() as u16;
(clipped, cells)
};
let diag_cells = if inputs.diag_chip.is_empty() {
0
} else {
inputs.diag_chip.chars().count() as u16 + 1
};
let verb_extra = inputs
.verb_split
.as_ref()
.map(|(verb, _)| verb.chars().count() as u16 + 3);
let icon_cells = if skip_icon {
1
} else {
2 + inputs.glyph.chars().count() as u16
};
let base_cells = icon_cells + name_cells + 1 + diag_cells + 2;
let chip_w = base_cells + verb_extra.unwrap_or(0);
let painted_w = chip_w.min(avail_width);
if painted_w == 0 {
return None;
}
let bg = if inputs.is_active { t.bg } else { strip_bg };
let name_fg = if inputs.is_active { t.fg } else { t.grey_fg };
let mut name_style = Style::default().fg(name_fg).bg(bg);
if inputs.is_active {
name_style = name_style.add_modifier(Modifier::BOLD);
}
if inputs.is_preview {
name_style = name_style.add_modifier(Modifier::ITALIC);
}
let badge_fg = if inputs.is_active {
badge_fg_active
} else {
badge_fg_inactive
};
let mut spans: Vec<Span<'static>> = Vec::new();
if skip_icon {
spans.push(Span::styled(" ".to_string(), Style::default().bg(bg)));
} else {
spans.push(Span::styled(
format!(" {} ", inputs.glyph),
Style::default().fg(inputs.icon_color).bg(bg),
));
}
if let Some((verb, _rest)) = &inputs.verb_split {
spans.push(Span::styled(
format!(" {verb} "),
Style::default()
.fg(bg)
.bg(inputs.icon_color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ".to_string(), Style::default().bg(bg)));
spans.push(Span::styled(format!("{name_clipped} "), name_style));
} else {
spans.push(Span::styled(format!("{name_clipped} "), name_style));
}
if !inputs.diag_chip.is_empty() {
let diag_fg = match inputs.diag_severity {
DiagChipSeverity::Error => t.red,
DiagChipSeverity::Warning => t.yellow,
DiagChipSeverity::None => t.yellow, };
spans.push(Span::styled(
format!("{} ", inputs.diag_chip),
Style::default().fg(diag_fg).bg(bg),
));
}
spans.push(Span::styled(
format!("{badge} "),
Style::default().fg(badge_fg).bg(bg),
));
Some((spans, painted_w))
}
pub fn paint_tab_chip(
frame: &mut Frame,
area: Rect,
inputs: &TabChipInputs,
strip_bg: ratatui::style::Color,
avail_width: u16,
nerd: bool,
) -> Option<TabChipRects> {
let (spans, painted_w) = tab_chip_spans(inputs, strip_bg, avail_width, nerd)?;
let chip_rect = Rect {
x: area.x,
y: area.y,
width: painted_w,
height: 1,
};
let bg = if inputs.is_active {
theme::cur().bg
} else {
strip_bg
};
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(bg)),
chip_rect,
);
let close = if (inputs.is_active || inputs.is_hovered) && !inputs.is_pinned && painted_w >= 2 {
Some(Rect {
x: chip_rect.x + chip_rect.width - 2,
y: chip_rect.y,
width: 2,
height: 1,
})
} else {
None
};
Some(TabChipRects {
chip: chip_rect,
close,
})
}
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme::cur().bg_darker)),
area,
);
app.rects.bufferline_tabs.clear();
app.rects.bufferline_tab_close.clear();
app.rects.bufferline_overflow_left = None;
app.rects.bufferline_overflow_right = None;
if area.width == 0 {
return;
}
paint_mode_chip_and_split_buttons(frame, app, area);
}
fn paint_mode_chip_and_split_buttons(frame: &mut Frame, app: &mut App, area: Rect) {
let t = theme::cur();
if matches!(app.layout(), crate::layout::Layout::Empty) {
let nerd = !app.config.ui.ascii_icons;
let plus_glyph = if nerd { "\u{F0415}" } else { "+" };
let plus_w = 3u16;
if area.width >= plus_w {
let plus_rect = Rect {
x: area.x,
y: area.y,
width: plus_w,
height: 1,
};
let plus_bg = t.bg;
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(plus_bg)),
Span::styled(
plus_glyph,
Style::default()
.fg(t.green)
.bg(plus_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().bg(plus_bg)),
])),
plus_rect,
);
app.rects.bufferline_empty_plus = Some(plus_rect);
}
}
let cluster_w = split_buttons_width(app);
if let Some((label, kind, pid)) = mode_chip_for_active(app) {
let chip_w = label.chars().count() as u16;
if area.width >= cluster_w + chip_w {
let chip_rect = Rect {
x: area.x + area.width - cluster_w - chip_w,
y: area.y,
width: chip_w,
height: 1,
};
let (fg, bg) = match kind {
ModeChipKind::EditorMd => (theme::cur().bg_darker, theme::cur().purple),
ModeChipKind::PreviewMd => (theme::cur().bg_darker, theme::cur().blue),
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
label.to_string(),
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
))),
chip_rect,
);
match kind {
ModeChipKind::EditorMd => {
app.rects.editor_md_preview_buttons.push((chip_rect, pid))
}
ModeChipKind::PreviewMd => app.rects.md_preview_edit_buttons.push((chip_rect, pid)),
}
}
}
paint_split_buttons(frame, app, area);
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ModeChipKind {
EditorMd,
PreviewMd,
}
fn mode_chip_for_active(app: &App) -> Option<(&'static str, ModeChipKind, crate::layout::PaneId)> {
let pid = app.active?;
mode_chip_for_pane(app, pid)
}
pub(crate) fn mode_chip_for_pane(
app: &App,
pid: crate::layout::PaneId,
) -> Option<(&'static str, ModeChipKind, crate::layout::PaneId)> {
let pane = app.panes.get(pid)?;
let ascii = app.config.ui.ascii_icons;
match pane {
crate::pane::Pane::Editor(b)
if b.path.as_deref().is_some_and(crate::app::is_markdown_path) =>
{
let label = if ascii {
" p Preview "
} else {
" \u{f06e} Preview "
};
Some((label, ModeChipKind::EditorMd, pid))
}
crate::pane::Pane::MdPreview(_) => {
let label = if ascii { " e Edit " } else { " \u{f044} Edit " };
Some((label, ModeChipKind::PreviewMd, pid))
}
_ => None,
}
}
pub fn right_cluster_width(app: &App) -> u16 {
let _ = app;
let mut w: u16 = 3;
w += 6;
for i in 0..app.layouts.len() {
let dig = (i + 1).to_string().chars().count() as u16;
w += 2 + dig;
if i == app.active_layout {
w += 2;
}
}
w += 4 + 3;
w
}
pub fn pick_cluster_mode(
area_x: u16,
area_w: u16,
palette_right_edge: u16,
full_w: u16,
gap: u16,
) -> Option<u16> {
let cluster_left = area_x + area_w.saturating_sub(full_w);
if cluster_left >= palette_right_edge + gap {
Some(full_w)
} else {
None
}
}
pub fn compact_cluster_width(app: &App) -> u16 {
let mut w: u16 = 3 + 4 + 3;
if app.layouts.len() >= 2 {
for i in 0..app.layouts.len() {
let dig = (i + 1).to_string().chars().count() as u16;
w += 2 + dig;
if i == app.active_layout {
w += 2; }
}
}
w
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClusterModePref {
Auto,
Expanded,
Compact,
}
impl ClusterModePref {
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"expanded" => Self::Expanded,
"compact" => Self::Compact,
_ => Self::Auto,
}
}
}
pub fn pick_cluster_mode_tiered(
app: &App,
area_x: u16,
area_w: u16,
palette_right_edge: u16,
full_w: u16,
gap: u16,
pref: ClusterModePref,
) -> Option<(u16, bool)> {
let full_fits = pick_cluster_mode(area_x, area_w, palette_right_edge, full_w, gap);
let compact_w = compact_cluster_width(app);
let compact_left = area_x + area_w.saturating_sub(compact_w);
let compact_fits = if compact_left >= palette_right_edge + gap {
Some((compact_w, true))
} else {
None
};
match pref {
ClusterModePref::Expanded => full_fits.map(|w| (w, false)).or(compact_fits),
ClusterModePref::Compact => compact_fits,
ClusterModePref::Auto => full_fits.map(|w| (w, false)).or(compact_fits),
}
}
pub fn paint_right_cluster(
frame: &mut Frame,
app: &mut App,
area: Rect,
bg: ratatui::style::Color,
compact: bool,
) {
app.rects.bufferline_new_tab_button = None;
app.rects.bufferline_new_request_button = None;
app.rects.bufferline_tab_page_chips.clear();
app.rects.bufferline_tab_page_close.clear();
app.rects.bufferline_tabs_label = None;
app.rects.bufferline_theme_toggle = None;
app.rects.bufferline_window_close = None;
app.rects.palette_stress_chip = None;
if area.width == 0 {
return;
}
let t = theme::cur();
let nerd = !app.config.ui.ascii_icons;
let mut spans: Vec<Span> = Vec::new();
let mut cluster_x = area.x;
let plus_glyph = if nerd { "\u{F0415}" } else { "+" };
spans.push(Span::styled(
format!(" {plus_glyph} "),
Style::default().fg(t.fg).bg(t.bg2),
));
app.rects.bufferline_new_tab_button = Some(Rect {
x: cluster_x,
y: area.y,
width: 3,
height: 1,
});
cluster_x += 3;
let show_tabs_label = !compact;
let show_chips = !compact || app.layouts.len() >= 2;
if show_tabs_label {
spans.push(Span::styled(
" TABS ",
Style::default()
.fg(t.bg_darker)
.bg(t.fg)
.add_modifier(Modifier::BOLD),
));
app.rects.bufferline_tabs_label = Some(Rect {
x: cluster_x,
y: area.y,
width: 6,
height: 1,
});
cluster_x += 6;
}
if show_chips {
for i in 0..app.layouts.len() {
let active = i == app.active_layout;
let dirty = app.tab_has_dirty_buffer(i);
let marker = if dirty { "\u{25CF}" } else { " " };
let label = format!("{marker}{} ", i + 1);
let label_w = label.chars().count() as u16;
let (chip_fg, chip_bg) = if active {
(t.bg_darker, t.blue)
} else {
(t.fg, t.bg2)
};
let mut chip_style = Style::default().fg(chip_fg).bg(chip_bg);
if active {
chip_style = chip_style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(label, chip_style));
app.rects.bufferline_tab_page_chips.push((
Rect {
x: cluster_x,
y: area.y,
width: label_w,
height: 1,
},
i,
));
cluster_x += label_w;
if active {
let close = if nerd { "\u{F0156} " } else { "x " };
spans.push(Span::styled(
close,
Style::default().fg(chip_fg).bg(chip_bg),
));
app.rects.bufferline_tab_page_close.push((
Rect {
x: cluster_x,
y: area.y,
width: 2,
height: 1,
},
i,
));
cluster_x += 2;
}
}
}
app.rects.palette_stress_chip = None;
{
let on_alt = app
.config
.ui
.theme_toggle
.as_deref()
.is_some_and(|alt| theme::cur().name.eq_ignore_ascii_case(alt));
spans.push(Span::styled(" ", Style::default().bg(t.bg2)));
if on_alt {
spans.push(Span::styled(
"\u{2501}",
Style::default().fg(t.comment).bg(t.bg2),
));
spans.push(Span::styled(
"\u{25CF}",
Style::default().fg(t.fg).bg(t.bg2),
));
} else {
spans.push(Span::styled(
"\u{25CF}",
Style::default().fg(t.fg).bg(t.bg2),
));
spans.push(Span::styled(
"\u{2501}",
Style::default().fg(t.comment).bg(t.bg2),
));
}
spans.push(Span::styled(" ", Style::default().bg(t.bg2)));
app.rects.bufferline_theme_toggle = Some(Rect {
x: cluster_x,
y: area.y,
width: 4,
height: 1,
});
cluster_x += 4;
}
spans.push(Span::styled(
" \u{F0156} ",
Style::default()
.fg(t.bg_darker)
.bg(t.red)
.add_modifier(Modifier::BOLD),
));
app.rects.bufferline_window_close = Some(Rect {
x: cluster_x,
y: area.y,
width: 3,
height: 1,
});
let _ = cluster_x;
let _ = bg; frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub const SPLIT_BUTTONS_W: u16 = 9;
pub const SPLIT_BUTTONS_W_WITH_AI: u16 = 12;
pub fn split_buttons_width(app: &App) -> u16 {
if app.config.ui.tab_bar_ai_icon == "none" {
return SPLIT_BUTTONS_W;
}
let n_ai = ["claude_code", "codex"]
.iter()
.filter(|id| {
app.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == **id && ic.enabled)
})
.count() as u16;
SPLIT_BUTTONS_W + n_ai * 3
}
pub fn paint_split_buttons(frame: &mut Frame, app: &mut App, area: Rect) {
let active_opt = app.active;
if area.width < SPLIT_BUTTONS_W {
return;
}
let t = theme::cur();
let nerd = !app.config.ui.ascii_icons;
let term_glyph_owned: String = if nerd {
app.integration_glyph_codepoints
.get("terminal")
.and_then(|&cp| char::from_u32(cp))
.map(|c| c.to_string())
.unwrap_or_else(|| "\u{ea85}".to_string())
} else {
"$".to_string()
};
let term_glyph = term_glyph_owned.as_str();
let side_by_side_glyph = if nerd { "\u{eb56}" } else { "|" };
let stacked_glyph = if nerd { "\u{eb57}" } else { "-" };
let bg = t.bg_darker;
let mut ai_kinds: Vec<&'static str> = Vec::new();
let ic_enabled = |id: &str| -> bool {
app.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == id && ic.enabled)
};
if ic_enabled("claude_code") {
ai_kinds.push("claude_code");
}
if ic_enabled("codex") {
ai_kinds.push("codex");
}
while area.width < SPLIT_BUTTONS_W + (ai_kinds.len() as u16) * 3 {
if ai_kinds.pop().is_none() {
break;
}
}
let total_w = SPLIT_BUTTONS_W + (ai_kinds.len() as u16) * 3;
let mut bx = area.x + area.width - total_w;
for kind in &ai_kinds {
let (ai_glyph, ai_fallback, ai_fg) =
theme::ai_chip_parts_for(kind, &t, app.config.ui.ai_chip_use_mnml_glyphs);
let glyph = if nerd { ai_glyph } else { ai_fallback };
let ai_rect = Rect {
x: bx,
y: area.y,
width: 3,
height: 1,
};
let ai_line = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(glyph, Style::default().fg(ai_fg).bg(bg)),
Span::styled(" ", Style::default().bg(bg)),
]);
frame.render_widget(Paragraph::new(ai_line), ai_rect);
let tag = if *kind == "codex" { 1u8 } else { 0u8 };
app.rects
.split_strip_ai_buttons
.push((ai_rect, active_opt, tag));
bx += 3;
}
let term_rect = Rect {
x: bx,
y: area.y,
width: 3,
height: 1,
};
let term_line = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
term_glyph,
Style::default().fg(ratatui::style::Color::White).bg(bg),
),
Span::styled(" ", Style::default().bg(bg)),
]);
frame.render_widget(Paragraph::new(term_line), term_rect);
app.rects
.split_strip_term_buttons
.push((term_rect, active_opt));
bx += 3;
for (glyph, dir) in [
(side_by_side_glyph, crate::layout::SplitDir::Horizontal),
(stacked_glyph, crate::layout::SplitDir::Vertical),
] {
let btn_rect = Rect {
x: bx,
y: area.y,
width: 3,
height: 1,
};
let line = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(glyph, Style::default().fg(t.comment).bg(bg)),
Span::styled(" ", Style::default().bg(bg)),
]);
frame.render_widget(Paragraph::new(line), btn_rect);
app.rects
.split_strip_buttons
.push((btn_rect, active_opt, dir));
bx += 3;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Buffer;
use crate::config::Config;
use std::fs;
use std::path::PathBuf;
#[allow(dead_code)]
fn ed(path: PathBuf) -> Pane {
let b = Buffer::open(&path, &Config::default()).unwrap();
Pane::Editor(b)
}
#[test]
fn diag_chip_prefers_errors_then_warnings_then_empty() {
use crate::lsp::{Diagnostic, Pos, Range, Severity};
let d = tempfile::tempdir().unwrap();
fs::write(d.path().join("a.rs"), "").unwrap();
let path = d.path().join("a.rs");
let r = Range {
start: Pos {
line: 0,
character: 0,
},
end: Pos {
line: 0,
character: 0,
},
};
let mk = |diags: Vec<Diagnostic>| {
let mut b = Buffer::open(&path, &Config::default()).unwrap();
b.diagnostics = diags;
Pane::Editor(b)
};
assert_eq!(
diag_chip_for(&mk(vec![]), "count"),
(String::new(), DiagChipSeverity::None)
);
let warn = || Diagnostic {
range: r,
severity: Severity::Warning,
message: "w".into(),
source: None,
};
assert_eq!(
diag_chip_for(&mk(vec![warn(), warn()]), "count"),
("\u{26A0}2".to_string(), DiagChipSeverity::Warning)
);
assert_eq!(
diag_chip_for(&mk(vec![warn(), warn()]), "dot"),
("\u{25CF}".to_string(), DiagChipSeverity::Warning)
);
assert_eq!(
diag_chip_for(&mk(vec![warn(), warn()]), "off"),
(String::new(), DiagChipSeverity::None)
);
let err = Diagnostic {
range: r,
severity: Severity::Error,
message: "e".into(),
source: None,
};
assert_eq!(
diag_chip_for(&mk(vec![warn(), warn(), err.clone()]), "count"),
("\u{2717}1".to_string(), DiagChipSeverity::Error)
);
assert_eq!(
diag_chip_for(&mk(vec![warn(), err]), "dot"),
("\u{25CF}".to_string(), DiagChipSeverity::Error)
);
}
#[test]
fn pick_cluster_mode_shows_full_at_generous_width() {
let mode = pick_cluster_mode(0, 200, 60, 50, 4);
assert_eq!(mode, Some(50));
}
#[test]
fn pick_cluster_mode_hides_when_cluster_would_overlap() {
let mode = pick_cluster_mode(0, 100, 60, 50, 4);
assert_eq!(mode, None);
}
#[test]
fn pick_cluster_mode_respects_area_x_offset() {
let mode = pick_cluster_mode(5, 100, 65, 50, 4);
assert_eq!(mode, None);
}
#[test]
fn pick_cluster_mode_gap_zero_lets_cluster_touch_palette() {
let mode = pick_cluster_mode(0, 100, 50, 50, 0);
assert_eq!(mode, Some(50));
}
#[test]
fn pick_cluster_mode_saturating_sub_doesnt_crash_on_tiny_widths() {
let mode = pick_cluster_mode(0, 10, 60, 50, 4);
assert_eq!(mode, None);
}
#[test]
fn pick_cluster_mode_zero_width_returns_none() {
let mode = pick_cluster_mode(0, 0, 0, 50, 4);
assert_eq!(mode, None);
}
fn base_inputs() -> TabChipInputs {
TabChipInputs {
id: 0,
glyph: "R".to_string(),
icon_color: crate::ui::theme::cur().cyan,
name: "file.rs".to_string(),
is_active: false,
is_dirty: false,
is_pinned: false,
is_preview: false,
is_hovered: false,
diag_chip: String::new(),
diag_severity: DiagChipSeverity::None,
verb_split: None,
name_cap: 32,
}
}
fn spans_to_text(spans: &[ratatui::text::Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
}
#[test]
fn chip_inactive_reads_glyph_name_and_blank_badge() {
let (spans, w) = tab_chip_spans(&base_inputs(), theme::cur().bg_darker, 40, true)
.expect("chip should paint");
let text = spans_to_text(&spans);
assert!(text.contains("R"), "icon glyph missing: {text:?}");
assert!(text.contains("file.rs"), "name missing: {text:?}");
assert!(
text.trim_end().ends_with("file.rs"),
"trailing badge should be blank space: {text:?}"
);
assert_eq!(
w,
text.chars().count() as u16,
"reported width must match painted width"
);
}
#[test]
fn chip_active_ends_with_close_glyph() {
let inputs = TabChipInputs {
is_active: true,
..base_inputs()
};
let (spans, _) = tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true).unwrap();
let text = spans_to_text(&spans);
assert!(
text.contains('\u{F0156}'),
"active chip should render close glyph: {text:?}"
);
}
#[test]
fn chip_pinned_wins_over_dirty_over_close() {
let pin_glyph = '\u{f08d}';
let inputs = TabChipInputs {
is_active: true,
is_dirty: true,
is_pinned: true,
..base_inputs()
};
let text = spans_to_text(
&tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true)
.unwrap()
.0,
);
assert!(
text.contains(pin_glyph),
"pinned should win over dirty/close: {text:?}"
);
assert!(
!text.contains('\u{F0156}'),
"close glyph should be absent when pinned: {text:?}"
);
}
#[test]
fn chip_dirty_shows_orange_dot_badge() {
let inputs = TabChipInputs {
is_dirty: true,
..base_inputs()
};
let text = spans_to_text(
&tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true)
.unwrap()
.0,
);
assert!(text.contains('●'), "dirty chip missing • badge: {text:?}");
}
#[test]
fn chip_preview_carries_italic_modifier() {
let inputs = TabChipInputs {
is_preview: true,
..base_inputs()
};
let spans = tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true)
.unwrap()
.0;
let name_span = spans
.iter()
.find(|s| s.content.contains("file.rs"))
.expect("name span present");
assert!(
name_span
.style
.add_modifier
.contains(ratatui::style::Modifier::ITALIC),
"preview name should be italic"
);
}
#[test]
fn chip_diagnostic_error_renders_red_chip_between_name_and_badge() {
let inputs = TabChipInputs {
diag_chip: "\u{2717}3".to_string(),
diag_severity: DiagChipSeverity::Error,
..base_inputs()
};
let spans = tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true)
.unwrap()
.0;
let text = spans_to_text(&spans);
let name_idx = text.find("file.rs").unwrap();
let diag_idx = text.find('\u{2717}').unwrap();
assert!(
diag_idx > name_idx,
"diag chip should sit right of the name"
);
let diag_span = spans
.iter()
.find(|s| s.content.contains('\u{2717}'))
.expect("diag span present");
assert_eq!(diag_span.style.fg, Some(theme::cur().red));
}
#[test]
fn chip_verb_split_renders_solid_verb_bg_before_url() {
let inputs = TabChipInputs {
glyph: String::new(), icon_color: theme::cur().green,
name: "https://api.example.com/foo".to_string(),
verb_split: Some(("GET".to_string(), "https://api.example.com/foo".to_string())),
..base_inputs()
};
let spans = tab_chip_spans(&inputs, theme::cur().bg_darker, 60, true)
.unwrap()
.0;
let text = spans_to_text(&spans);
let verb_idx = text.find("GET").unwrap();
let url_idx = text.find("api.example.com").unwrap();
assert!(verb_idx < url_idx, "verb should render before url");
let verb_span = spans
.iter()
.find(|s| s.content.contains(" GET "))
.expect("verb span present");
assert_eq!(verb_span.style.bg, Some(theme::cur().green));
}
#[test]
fn chip_reports_true_painted_width_including_verb_extra() {
let inputs = TabChipInputs {
glyph: String::new(),
icon_color: theme::cur().green,
name: "GET /api/foo".to_string(),
verb_split: Some(("GET".to_string(), "/api/foo".to_string())),
..base_inputs()
};
let (spans, w) = tab_chip_spans(&inputs, theme::cur().bg_darker, 40, true).unwrap();
let painted = spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>() as u16;
assert_eq!(w, painted, "reported width must match summed span chars");
}
#[test]
fn chip_verb_split_clips_long_url_with_ellipsis() {
let inputs = TabChipInputs {
glyph: String::new(),
icon_color: theme::cur().green,
name: "GET https://api.example.com/very/deep/nested/path/segment".to_string(),
verb_split: Some((
"GET".to_string(),
"https://api.example.com/very/deep/nested/path/segment".to_string(),
)),
name_cap: 18,
..base_inputs()
};
let (spans, _) = tab_chip_spans(&inputs, theme::cur().bg_darker, 100, true).unwrap();
let text = spans_to_text(&spans);
assert!(
text.contains('\u{2026}'),
"long verb-split URL should end with `…`: {text:?}"
);
}
#[test]
fn chip_returns_none_when_avail_is_zero() {
assert!(tab_chip_spans(&base_inputs(), theme::cur().bg_darker, 0, true).is_none());
}
#[test]
fn chip_paint_registers_close_rect_only_for_active_or_hovered_unpinned() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let cases: Vec<(TabChipInputs, bool, &str)> = vec![
(base_inputs(), false, "inactive-not-hovered"),
(
TabChipInputs {
is_active: true,
..base_inputs()
},
true,
"active-clean-unpinned",
),
(
TabChipInputs {
is_active: true,
is_dirty: true,
..base_inputs()
},
true,
"active-dirty",
),
(
TabChipInputs {
is_active: true,
is_pinned: true,
..base_inputs()
},
false,
"active-pinned",
),
(
TabChipInputs {
is_hovered: true,
..base_inputs()
},
true,
"inactive-hovered",
),
(
TabChipInputs {
is_hovered: true,
is_dirty: true,
..base_inputs()
},
true,
"inactive-hovered-dirty",
),
(
TabChipInputs {
is_hovered: true,
is_pinned: true,
..base_inputs()
},
false,
"hovered-pinned",
),
];
for (inputs, expect_close, label) in cases {
let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
let mut got_close = false;
term.draw(|f| {
let rects = paint_tab_chip(
f,
Rect::new(0, 0, 40, 1),
&inputs,
theme::cur().bg_darker,
40,
true,
);
got_close = rects.and_then(|r| r.close).is_some();
})
.unwrap();
assert_eq!(
got_close, expect_close,
"close-rect presence mismatch for {label}"
);
}
}
}