use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::sidebar::Container;
use crate::tmux::detector::Status;
use crate::tmux::session::SessionView;
use crate::ui::Theme;
const TAB_PAD: u16 = 1;
const PLUS_LABEL: &str = " + ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Slot {
pub key: String,
pub rect: Rect,
}
#[derive(Debug, Clone, Default)]
pub struct Layout {
pub tabs: Vec<Slot>,
pub plus: Option<Slot>,
pub first_visible: usize,
pub last_visible: usize,
}
impl Layout {
pub fn hit(&self, col: u16, row: u16) -> Option<&Slot> {
if let Some(p) = &self.plus {
if contains(p.rect, col, row) {
return Some(p);
}
}
self.tabs.iter().find(|t| contains(t.rect, col, row))
}
}
fn contains(r: Rect, col: u16, row: u16) -> bool {
col >= r.x
&& col < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
pub fn compute(area: Rect, tab_labels: &[&str], active_idx: Option<usize>) -> Layout {
let mut out = Layout::default();
if area.width == 0 || area.height == 0 || tab_labels.is_empty() {
out.plus = plus_slot(area);
return out;
}
let plus_w = PLUS_LABEL.chars().count() as u16;
if area.width <= plus_w {
return out;
}
let available = area.width.saturating_sub(plus_w);
let widths: Vec<u16> = tab_labels
.iter()
.map(|l| TAB_PAD * 2 + 2 + l.chars().count() as u16)
.collect();
let mut first = 0usize;
let mut last_excl = 0usize;
let mut acc: u16 = 0;
for (i, w) in widths.iter().enumerate() {
if acc.saturating_add(*w) > available {
break;
}
acc = acc.saturating_add(*w);
last_excl = i + 1;
}
if let Some(a) = active_idx {
if a >= last_excl {
last_excl = a + 1;
let mut acc2: u16 = 0;
first = a + 1;
for i in (0..last_excl).rev() {
if acc2.saturating_add(widths[i]) > available {
break;
}
acc2 = acc2.saturating_add(widths[i]);
first = i;
}
}
}
let mut x = area.x;
for w in widths.iter().take(last_excl).skip(first) {
out.tabs.push(Slot {
key: String::new(),
rect: Rect::new(x, area.y, *w, 1),
});
x = x.saturating_add(*w);
}
out.first_visible = first;
out.last_visible = last_excl;
out.plus = Some(Slot {
key: "+".to_string(),
rect: Rect::new(x, area.y, plus_w, 1),
});
out
}
fn plus_slot(area: Rect) -> Option<Slot> {
let plus_w = PLUS_LABEL.chars().count() as u16;
if area.width < plus_w || area.height == 0 {
return None;
}
Some(Slot {
key: "+".to_string(),
rect: Rect::new(area.x, area.y, plus_w, 1),
})
}
pub fn render(
buf: &mut Buffer,
area: Rect,
container: &Container,
tab_views: &[Option<&SessionView>],
theme: &Theme,
) -> Layout {
let resolved: Vec<(String, Status)> = container
.members
.iter()
.enumerate()
.map(|(i, internal)| match tab_views.get(i).and_then(|v| *v) {
Some(v) => (v.display().to_string(), v.status),
None => (internal.clone(), Status::Unknown),
})
.collect();
let label_refs: Vec<&str> = resolved.iter().map(|(l, _)| l.as_str()).collect();
let active_idx = container
.members
.iter()
.position(|m| m == &container.active);
let mut layout = compute(area, &label_refs, active_idx);
let bg_style = Style::default().bg(theme.panel).fg(theme.text_muted);
for x in area.left()..area.right() {
let cell = &mut buf[(x, area.y)];
cell.set_char(' ');
cell.set_style(bg_style);
}
for (i, slot) in layout.tabs.iter_mut().enumerate() {
let member_idx = layout.first_visible + i;
let internal = match container.members.get(member_idx) {
Some(m) => m,
None => continue,
};
slot.key = internal.clone();
let (label, status) = &resolved[member_idx];
let active = internal == &container.active;
let style = if active {
Style::default().bg(theme.accent).fg(theme.on(theme.accent))
} else {
Style::default().bg(theme.panel).fg(theme.text_muted)
};
let glyph = status.glyph();
let pill = format!(" {} {} ", glyph, label);
let mut col = slot.rect.x;
for ch in pill.chars() {
if col >= slot.rect.x.saturating_add(slot.rect.width) {
break;
}
let cell = &mut buf[(col, slot.rect.y)];
cell.set_char(ch);
cell.set_style(style);
col = col.saturating_add(1);
}
}
if let Some(plus) = &layout.plus {
let style = Style::default().bg(theme.panel_alt).fg(theme.accent);
let mut col = plus.rect.x;
for ch in PLUS_LABEL.chars() {
if col >= plus.rect.x.saturating_add(plus.rect.width) {
break;
}
let cell = &mut buf[(col, plus.rect.y)];
cell.set_char(ch);
cell.set_style(style);
col = col.saturating_add(1);
}
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_lays_tabs_left_to_right_with_plus_after_last_tab() {
let area = Rect::new(0, 5, 40, 1);
let layout = compute(area, &["one", "two"], Some(0));
assert_eq!(layout.tabs.len(), 2);
assert_eq!(layout.tabs[0].rect, Rect::new(0, 5, 7, 1));
assert_eq!(layout.tabs[1].rect, Rect::new(7, 5, 7, 1));
assert_eq!(layout.plus.as_ref().unwrap().rect, Rect::new(14, 5, 3, 1));
assert_eq!(layout.first_visible, 0);
assert_eq!(layout.last_visible, 2);
}
#[test]
fn compute_truncates_overflow_keeping_plus() {
let area = Rect::new(0, 0, 25, 1);
let layout = compute(area, &["one", "two", "thr", "fou", "fiv"], Some(0));
assert_eq!(layout.tabs.len(), 3);
assert!(layout.plus.is_some());
}
#[test]
fn compute_scrolls_window_to_keep_active_visible() {
let area = Rect::new(0, 0, 25, 1);
let layout = compute(area, &["one", "two", "thr", "fou", "fiv"], Some(4));
assert_eq!(layout.first_visible, 2);
assert_eq!(layout.last_visible, 5);
assert_eq!(layout.tabs.len(), 3);
}
#[test]
fn hit_resolves_plus_then_tabs() {
let area = Rect::new(0, 0, 40, 1);
let layout = compute(area, &["one", "two"], Some(0));
let hit = layout.hit(3, 0).unwrap();
assert_eq!(hit.rect, Rect::new(0, 0, 7, 1));
let hit = layout.hit(15, 0).unwrap();
assert_eq!(hit.key, "+");
assert!(layout.hit(20, 0).is_none());
}
}