use crate::app::BufferMetadata;
use crate::model::event::BufferId;
use crate::state::EditorState;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use std::collections::HashMap;
pub struct TabsRenderer;
pub fn compute_tab_scroll_offset(
tab_widths: &[usize],
active_idx: usize,
max_width: usize,
current_offset: usize,
padding_between_tabs: usize,
) -> usize {
if tab_widths.is_empty() || max_width == 0 {
return 0;
}
let total_width: usize = tab_widths.iter().sum::<usize>()
+ padding_between_tabs.saturating_mul(tab_widths.len().saturating_sub(1));
let mut tab_start = 0usize;
let mut tab_end = 0usize;
let mut tab_counter = 0usize;
for w in tab_widths {
let next = tab_start + *w;
if tab_counter == active_idx {
tab_end = next;
break;
}
tab_start = next + padding_between_tabs;
tab_counter += 1;
}
if tab_end == 0 {
return current_offset.min(total_width.saturating_sub(max_width));
}
let mut offset = tab_start;
if tab_end.saturating_sub(offset) > max_width {
offset = tab_end.saturating_sub(max_width);
}
offset.min(total_width.saturating_sub(max_width))
}
#[cfg(test)]
mod tests {
use super::compute_tab_scroll_offset;
#[test]
fn offset_clamped_to_zero_when_active_first() {
let widths = vec![5, 5, 5]; let offset = compute_tab_scroll_offset(&widths, 0, 6, 10, 1);
assert_eq!(offset, 0);
}
#[test]
fn offset_moves_to_show_active_tab() {
let widths = vec![5, 8, 6]; let offset = compute_tab_scroll_offset(&widths, 1, 6, 0, 1);
assert_eq!(offset, 8);
}
#[test]
fn offset_respects_total_width_bounds() {
let widths = vec![3, 3, 3, 3];
let offset = compute_tab_scroll_offset(&widths, 3, 4, 100, 1);
let total: usize = widths.iter().sum();
let total_with_padding = total + 3; assert!(offset <= total_with_padding.saturating_sub(4));
}
}
impl TabsRenderer {
pub fn render_for_split(
frame: &mut Frame,
area: Rect,
split_buffers: &[BufferId],
buffers: &HashMap<BufferId, EditorState>,
buffer_metadata: &HashMap<BufferId, BufferMetadata>,
active_buffer: BufferId,
theme: &crate::view::theme::Theme,
is_active_split: bool,
tab_scroll_offset: usize,
hovered_tab: Option<(BufferId, bool)>, ) -> Vec<(BufferId, u16, u16, u16)> {
const SCROLL_INDICATOR_LEFT: &str = "<";
const SCROLL_INDICATOR_RIGHT: &str = ">";
const SCROLL_INDICATOR_WIDTH: usize = 1;
let mut all_tab_spans: Vec<(Span, usize)> = Vec::new(); let mut tab_ranges: Vec<(usize, usize, usize)> = Vec::new(); let mut rendered_buffer_ids: Vec<BufferId> = Vec::new();
for (idx, id) in split_buffers.iter().enumerate() {
let Some(state) = buffers.get(id) else {
continue;
};
rendered_buffer_ids.push(*id);
let meta = buffer_metadata.get(id);
let is_terminal = meta
.and_then(|m| m.virtual_mode())
.map(|mode| mode == "terminal")
.unwrap_or(false);
let name = if is_terminal {
meta.map(|m| m.display_name.as_str())
} else {
state
.buffer
.file_path()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.or_else(|| meta.map(|m| m.display_name.as_str()))
}
.unwrap_or("[No Name]");
let modified = if state.buffer.is_modified() { "*" } else { "" };
let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
" [BIN]"
} else {
""
};
let is_active = *id == active_buffer;
let (is_hovered_name, is_hovered_close) = match hovered_tab {
Some((hover_buf, is_close)) if hover_buf == *id => (!is_close, is_close),
_ => (false, false),
};
let base_style = if is_active {
if is_active_split {
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_active_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_inactive_bg)
.add_modifier(Modifier::BOLD)
}
} else if is_hovered_name {
Style::default()
.fg(theme.tab_inactive_fg)
.bg(theme.tab_hover_bg)
} else {
Style::default()
.fg(theme.tab_inactive_fg)
.bg(theme.tab_inactive_bg)
};
let close_style = if is_hovered_close {
base_style.fg(theme.tab_close_hover_fg)
} else {
base_style
};
let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
let tab_name_width = tab_name_text.chars().count();
let close_text = "× ";
let close_width = close_text.chars().count();
let total_width = tab_name_width + close_width;
let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
let close_start_pos = start_pos + tab_name_width;
let end_pos = start_pos + total_width;
tab_ranges.push((start_pos, end_pos, close_start_pos));
all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
all_tab_spans.push((
Span::styled(close_text.to_string(), close_style),
close_width,
));
if idx < split_buffers.len() - 1 {
all_tab_spans.push((
Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
1,
));
}
}
let mut current_spans: Vec<Span> = Vec::new();
let max_width = area.width as usize;
let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
let active_tab_idx = rendered_buffer_ids
.iter()
.position(|id| *id == active_buffer);
let mut tab_widths: Vec<usize> = Vec::new();
for (start, end, _close_start) in &tab_ranges {
tab_widths.push(end.saturating_sub(*start));
}
let mut offset = tab_scroll_offset.min(total_width.saturating_sub(max_width));
if let Some(active_idx) = active_tab_idx {
offset = compute_tab_scroll_offset(
&tab_widths,
active_idx,
max_width,
tab_scroll_offset,
1, );
}
let mut show_left = offset > 0;
let mut show_right = total_width.saturating_sub(offset) > max_width;
let mut available = max_width
.saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
if let Some(active_idx) = active_tab_idx {
let (start, end, _close_start) = tab_ranges[active_idx];
let active_width = end.saturating_sub(start);
if start == 0 && active_width >= max_width {
show_left = false;
show_right = false;
available = max_width;
}
if end.saturating_sub(offset) > available {
offset = end.saturating_sub(available);
offset = offset.min(total_width.saturating_sub(available));
show_left = offset > 0;
show_right = total_width.saturating_sub(offset) > available;
available = max_width.saturating_sub(
(show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH,
);
}
if start < offset {
offset = start;
show_left = offset > 0;
show_right = total_width.saturating_sub(offset) > available;
}
}
let mut rendered_width = 0;
let mut skip_chars_count = offset;
if show_left {
current_spans.push(Span::styled(
SCROLL_INDICATOR_LEFT,
Style::default().bg(theme.tab_separator_bg),
));
rendered_width += SCROLL_INDICATOR_WIDTH;
}
for (mut span, width) in all_tab_spans.into_iter() {
if skip_chars_count >= width {
skip_chars_count -= width;
continue;
}
let visible_chars_in_span = width - skip_chars_count;
if rendered_width + visible_chars_in_span
> max_width.saturating_sub(if show_right {
SCROLL_INDICATOR_WIDTH
} else {
0
})
{
let remaining_width =
max_width
.saturating_sub(rendered_width)
.saturating_sub(if show_right {
SCROLL_INDICATOR_WIDTH
} else {
0
});
let truncated_content = span
.content
.chars()
.skip(skip_chars_count)
.take(remaining_width)
.collect::<String>();
span.content = std::borrow::Cow::Owned(truncated_content);
current_spans.push(span);
rendered_width += remaining_width;
break;
} else {
let visible_content = span
.content
.chars()
.skip(skip_chars_count)
.collect::<String>();
span.content = std::borrow::Cow::Owned(visible_content);
current_spans.push(span);
rendered_width += visible_chars_in_span;
skip_chars_count = 0;
}
}
if show_right && rendered_width < max_width {
current_spans.push(Span::styled(
SCROLL_INDICATOR_RIGHT,
Style::default().bg(theme.tab_separator_bg),
));
rendered_width += SCROLL_INDICATOR_WIDTH;
}
if rendered_width < max_width {
current_spans.push(Span::styled(
" ".repeat(max_width.saturating_sub(rendered_width)),
Style::default().bg(theme.tab_separator_bg),
));
}
let line = Line::from(current_spans);
let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
let mut hit_areas = Vec::new();
for (idx, buffer_id) in rendered_buffer_ids.iter().enumerate() {
let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
let visible_start = offset;
let visible_end = offset + available;
if logical_end <= visible_start || logical_start >= visible_end {
continue;
}
let screen_start = if logical_start >= visible_start {
area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
} else {
area.x + left_indicator_offset as u16
};
let screen_end = if logical_end <= visible_end {
area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
} else {
area.x + left_indicator_offset as u16 + available as u16
};
let screen_close_start = if logical_close_start >= visible_start
&& logical_close_start < visible_end
{
area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
} else if logical_close_start < visible_start {
screen_start
} else {
screen_end
};
hit_areas.push((*buffer_id, screen_start, screen_end, screen_close_start));
}
hit_areas
}
#[allow(dead_code)]
pub fn render(
frame: &mut Frame,
area: Rect,
buffers: &HashMap<BufferId, EditorState>,
buffer_metadata: &HashMap<BufferId, BufferMetadata>,
active_buffer: BufferId,
theme: &crate::view::theme::Theme,
) {
let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
buffer_ids.sort_by_key(|id| id.0);
Self::render_for_split(
frame,
area,
&buffer_ids,
buffers,
buffer_metadata,
active_buffer,
theme,
true, 0, None, );
}
}