use std::collections::HashSet;
use std::path::{Path, PathBuf};
use alacritty_terminal::event::Event as TermEvent;
use anyhow::Result;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use unicode_width::UnicodeWidthStr as _;
use super::layout::{self, MIN_GROUP_HEIGHT};
use super::terminal::{GridSize, TabEffect, TabId, TabKind, TerminalTab};
pub struct PaneGroup {
pub tabs: Vec<TerminalTab>,
pub active: usize,
}
impl PaneGroup {
fn new(tab: TerminalTab) -> Self {
Self {
tabs: vec![tab],
active: 0,
}
}
pub fn active_tab(&self) -> Option<&TerminalTab> {
self.tabs.get(self.active)
}
pub fn active_tab_mut(&mut self) -> Option<&mut TerminalTab> {
self.tabs.get_mut(self.active)
}
fn clamp_active(&mut self) {
if self.active >= self.tabs.len() {
self.active = self.tabs.len().saturating_sub(1);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabAddr {
pub group: usize,
pub tab: usize,
}
#[derive(Default)]
pub struct PaneLayout {
pub groups: Vec<PaneGroup>,
pub weights: Vec<u16>,
pub focused: usize,
next_id: TabId,
}
impl PaneLayout {
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
pub fn group_count(&self) -> usize {
self.groups.len()
}
fn take_id(&mut self) -> TabId {
self.next_id += 1;
self.next_id
}
fn focused_group(&self) -> Option<&PaneGroup> {
self.groups.get(self.focused)
}
pub fn active_tab(&self) -> Option<&TerminalTab> {
self.focused_group()?.active_tab()
}
pub fn group_tab(&self, index: usize) -> Option<&TerminalTab> {
self.groups.get(index)?.active_tab()
}
pub fn tabs(&self) -> impl Iterator<Item = &TerminalTab> {
self.groups.iter().flat_map(|g| g.tabs.iter())
}
pub fn any_alive(&self) -> bool {
self.tabs().any(TerminalTab::is_alive)
}
pub fn find(&self, id: TabId) -> Option<TabAddr> {
self.groups.iter().enumerate().find_map(|(group, g)| {
g.tabs
.iter()
.position(|t| t.id() == id)
.map(|tab| TabAddr { group, tab })
})
}
pub fn tab_mut(&mut self, addr: TabAddr) -> Option<&mut TerminalTab> {
self.groups.get_mut(addr.group)?.tabs.get_mut(addr.tab)
}
pub fn find_in_worktree(&self, worktree: &Path, kind: TabKind) -> Option<TabAddr> {
self.groups.iter().enumerate().find_map(|(group, g)| {
g.tabs
.iter()
.position(|t| t.opened_in == worktree && t.kind == kind && t.is_alive())
.map(|tab| TabAddr { group, tab })
})
}
pub fn focus(&mut self, addr: TabAddr) {
if let Some(group) = self.groups.get_mut(addr.group) {
if addr.tab < group.tabs.len() {
group.active = addr.tab;
self.focused = addr.group;
}
}
}
pub fn open_tab(
&mut self,
spawn: impl FnOnce(TabId) -> Result<TerminalTab>,
) -> Result<TabAddr> {
let id = self.take_id();
let tab = spawn(id)?;
if self.groups.is_empty() {
self.groups.push(PaneGroup::new(tab));
self.weights = layout::even_weights(1);
self.focused = 0;
return Ok(TabAddr { group: 0, tab: 0 });
}
let group = self.focused.min(self.groups.len() - 1);
self.groups[group].tabs.push(tab);
let tab = self.groups[group].tabs.len() - 1;
self.groups[group].active = tab;
self.focused = group;
Ok(TabAddr { group, tab })
}
pub fn split(&mut self, spawn: impl FnOnce(TabId) -> Result<TerminalTab>) -> Result<TabAddr> {
if self.groups.is_empty() {
return self.open_tab(spawn);
}
let id = self.take_id();
let tab = spawn(id)?;
let at = (self.focused + 1).min(self.groups.len());
self.groups.insert(at, PaneGroup::new(tab));
self.weights.insert(at, average_weight(&self.weights));
self.focused = at;
Ok(TabAddr { group: at, tab: 0 })
}
pub fn close_tab(&mut self, addr: TabAddr) -> Option<PathBuf> {
let group = self.groups.get_mut(addr.group)?;
if addr.tab >= group.tabs.len() {
return None;
}
let mut tab = group.tabs.remove(addr.tab);
tab.shutdown();
group.clamp_active();
if group.tabs.is_empty() {
self.groups.remove(addr.group);
if addr.group < self.weights.len() {
self.weights.remove(addr.group);
}
if self.focused >= self.groups.len() {
self.focused = self.groups.len().saturating_sub(1);
}
} else {
self.focused = addr.group;
}
Some(tab.opened_in)
}
pub fn close_active(&mut self) -> Option<PathBuf> {
let addr = TabAddr {
group: self.focused,
tab: self.focused_group()?.active,
};
self.close_tab(addr)
}
pub fn open_worktrees(&self) -> HashSet<PathBuf> {
self.tabs().map(|t| t.opened_in.clone()).collect()
}
pub fn cycle_tab(&mut self, delta: isize) {
let Some(group) = self.groups.get_mut(self.focused) else {
return;
};
let len = group.tabs.len();
if len == 0 {
return;
}
let len_i = isize::try_from(len).unwrap_or(isize::MAX);
let current = isize::try_from(group.active).unwrap_or(0);
group.active = usize::try_from((current + delta).rem_euclid(len_i)).unwrap_or(0);
}
pub fn select_tab(&mut self, index: usize) -> bool {
match self.groups.get_mut(self.focused) {
Some(group) if index < group.tabs.len() => {
group.active = index;
true
}
_ => false,
}
}
pub fn cycle_group(&mut self, delta: isize) {
if self.groups.is_empty() {
return;
}
let max = isize::try_from(self.groups.len() - 1).unwrap_or(0);
let current = isize::try_from(self.focused).unwrap_or(0);
self.focused = usize::try_from((current + delta).clamp(0, max)).unwrap_or(0);
}
pub fn move_tab_to_group(&mut self, delta: isize) -> bool {
if self.groups.len() < 2 {
return false;
}
let from = self.focused;
let Ok(target) = usize::try_from(isize::try_from(from).unwrap_or(0) + delta) else {
return false;
};
if target >= self.groups.len() {
return false;
}
let Some(group) = self.groups.get_mut(from) else {
return false;
};
if group.tabs.is_empty() {
return false;
}
let tab = group.tabs.remove(group.active);
group.clamp_active();
let emptied = group.tabs.is_empty();
let target = if emptied && target > from {
target - 1
} else {
target
};
if emptied {
self.groups.remove(from);
if from < self.weights.len() {
self.weights.remove(from);
}
}
let Some(dest) = self.groups.get_mut(target) else {
return false; };
dest.tabs.push(tab);
dest.active = dest.tabs.len() - 1;
self.focused = target;
true
}
pub fn reset_weights(&mut self) {
self.weights = layout::even_weights(self.groups.len());
}
pub fn handle_event(&mut self, id: TabId, event: TermEvent) -> Option<TabEffect> {
let addr = self.find(id)?;
Some(self.tab_mut(addr)?.handle_event(event))
}
pub fn arrange(&mut self, area: Rect) -> Vec<GroupRects> {
let rects = layout::split_groups(area, &self.weights);
let mut out = Vec::with_capacity(rects.len());
for (index, rect) in rects.into_iter().enumerate() {
let strip = Rect::new(rect.x, rect.y, rect.width, 1);
let body = Rect::new(
rect.x,
rect.y + 1,
rect.width,
rect.height.saturating_sub(1),
);
let grid = Block::default().borders(Borders::ALL).inner(body);
if let Some(tab) = self
.groups
.get_mut(index)
.and_then(PaneGroup::active_tab_mut)
{
tab.resize(GridSize {
cols: grid.width,
lines: grid.height,
});
}
let tab_spans = self
.groups
.get(index)
.map(|g| strip_cells(g, strip).iter().map(|c| c.span).collect())
.unwrap_or_default();
out.push(GroupRects {
strip,
body,
grid,
index,
tab_spans,
});
}
out
}
pub fn draw(&self, frame: &mut Frame<'_>, rects: &[GroupRects], focused_pane: bool) {
for group_rects in rects {
let Some(group) = self.groups.get(group_rects.index) else {
continue;
};
let is_focused = focused_pane && group_rects.index == self.focused;
draw_tab_strip(frame, group_rects.strip, group, is_focused);
if let Some(tab) = group.active_tab() {
tab.draw(frame, group_rects.body, is_focused);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupRects {
pub strip: Rect,
pub body: Rect,
pub grid: Rect,
pub index: usize,
pub tab_spans: Vec<(u16, u16)>,
}
struct StripCell {
text: String,
span: (u16, u16),
}
fn strip_cells(group: &PaneGroup, area: Rect) -> Vec<StripCell> {
let mut cells = Vec::with_capacity(group.tabs.len());
let mut x = area.x;
let end = area.x.saturating_add(area.width);
for (index, tab) in group.tabs.iter().enumerate() {
let text = format!(" {} {} ", index + 1, tab.strip_label());
let width = u16::try_from(text.width()).unwrap_or(u16::MAX);
if x >= end {
break; }
let stop = x.saturating_add(width).min(end);
cells.push(StripCell {
text,
span: (x, stop),
});
x = stop.saturating_add(1);
}
cells
}
fn average_weight(weights: &[u16]) -> u16 {
if weights.is_empty() {
return 1;
}
let total: u32 = weights.iter().map(|w| u32::from(*w)).sum();
u16::try_from(total / u32::try_from(weights.len()).unwrap_or(1))
.unwrap_or(1)
.max(1)
}
fn draw_tab_strip(frame: &mut Frame<'_>, area: Rect, group: &PaneGroup, focused: bool) {
if area.height == 0 {
return;
}
let mut spans = Vec::with_capacity(group.tabs.len() * 2);
for (index, cell) in strip_cells(group, area).into_iter().enumerate() {
let mut style = Style::default();
if index == group.active {
style = style.add_modifier(Modifier::REVERSED);
if focused {
style = style.fg(Color::Cyan);
}
} else {
style = style.fg(Color::DarkGray);
}
spans.push(Span::styled(cell.text, style));
spans.push(Span::raw(" "));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn min_height_for(count: usize) -> u16 {
u16::try_from(count)
.unwrap_or(u16::MAX)
.saturating_mul(MIN_GROUP_HEIGHT)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[cfg_attr(not(unix), allow(unused_imports))]
use tokio::sync::mpsc;
#[cfg(unix)]
fn spawner(
script: &'static str,
cwd: PathBuf,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
) -> impl FnOnce(TabId) -> Result<TerminalTab> {
move |id| {
let request = super::super::terminal::pty::SpawnRequest {
tab: id,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
)),
cwd,
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
TerminalTab::from_request(TabKind::Shell, request, tx)
}
}
#[cfg(unix)]
fn layout_with(count: usize) -> (PaneLayout, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let (tx, _rx) = mpsc::unbounded_channel();
let mut panes = PaneLayout::default();
for _ in 0..count {
panes
.split(spawner("sleep 5", dir.path().to_path_buf(), tx.clone()))
.unwrap();
}
(panes, dir)
}
#[test]
fn an_empty_layout_has_no_groups_and_no_active_tab() {
let panes = PaneLayout::default();
assert!(panes.is_empty());
assert_eq!(panes.group_count(), 0);
assert!(panes.active_tab().is_none());
assert!(!panes.any_alive());
assert!(panes.find(1).is_none());
assert!(panes.group_tab(0).is_none());
assert!(panes.open_worktrees().is_empty());
}
#[test]
fn average_weight_of_nothing_is_one() {
assert_eq!(average_weight(&[]), 1);
assert_eq!(average_weight(&[2, 4]), 3);
assert_eq!(average_weight(&[0, 0]), 1, "never zero");
}
#[test]
fn min_height_scales_with_the_group_count() {
assert_eq!(min_height_for(1), MIN_GROUP_HEIGHT);
assert_eq!(min_height_for(3), MIN_GROUP_HEIGHT * 3);
}
#[cfg(unix)]
#[tokio::test]
async fn opening_adds_to_the_focused_group_and_splitting_makes_a_new_one() {
let (mut panes, dir) = layout_with(0);
let (tx, _rx) = mpsc::unbounded_channel();
let spawn = || spawner("sleep 5", dir.path().to_path_buf(), tx.clone());
let first = panes.open_tab(spawn()).unwrap();
assert_eq!(first, TabAddr { group: 0, tab: 0 });
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.weights, vec![1]);
assert!(panes.any_alive());
let second = panes.open_tab(spawn()).unwrap();
assert_eq!(second, TabAddr { group: 0, tab: 1 });
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.groups[0].tabs.len(), 2);
assert_eq!(panes.groups[0].active, 1, "a new tab takes focus");
let split = panes.split(spawn()).unwrap();
assert_eq!(split, TabAddr { group: 1, tab: 0 });
assert_eq!(panes.group_count(), 2);
assert_eq!(panes.weights.len(), 2);
assert_eq!(panes.focused, 1);
let ids: Vec<TabId> = panes.tabs().map(TerminalTab::id).collect();
let unique: HashSet<TabId> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len());
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn closing_removes_the_tab_then_its_group_and_reports_the_worktree() {
let (mut panes, dir) = layout_with(2);
let here = dir.path().to_path_buf();
assert_eq!(panes.group_count(), 2);
let closed = panes.close_active().unwrap();
assert_eq!(closed, here);
assert_eq!(panes.group_count(), 1, "the emptied group went with it");
assert_eq!(panes.weights.len(), 1);
assert_eq!(panes.focused, 0);
assert!(panes.open_worktrees().contains(&here), "one tab remains");
assert_eq!(panes.close_active(), Some(here));
assert!(panes.is_empty());
assert!(panes.open_worktrees().is_empty());
assert_eq!(panes.focused, 0);
assert!(panes.close_active().is_none(), "nothing left to close");
assert!(panes.close_tab(TabAddr { group: 9, tab: 9 }).is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn tabs_and_groups_cycle_select_and_move() {
let (mut panes, dir) = layout_with(1);
let (tx, _rx) = mpsc::unbounded_channel();
let spawn = || spawner("sleep 5", dir.path().to_path_buf(), tx.clone());
panes.open_tab(spawn()).unwrap();
panes.open_tab(spawn()).unwrap(); assert_eq!(panes.groups[0].tabs.len(), 3);
assert_eq!(panes.groups[0].active, 2);
panes.cycle_tab(1);
assert_eq!(panes.groups[0].active, 0, "wraps forward");
panes.cycle_tab(-1);
assert_eq!(panes.groups[0].active, 2, "wraps back");
assert!(panes.select_tab(1));
assert_eq!(panes.groups[0].active, 1);
assert!(!panes.select_tab(9), "out of range selects nothing");
assert_eq!(panes.groups[0].active, 1);
panes.split(spawn()).unwrap();
assert_eq!(panes.focused, 1);
panes.cycle_group(-1);
assert_eq!(panes.focused, 0);
panes.cycle_group(-1);
assert_eq!(panes.focused, 0, "clamped, not wrapped");
panes.cycle_group(5);
assert_eq!(panes.focused, 1);
assert!(panes.move_tab_to_group(-1));
assert_eq!(panes.group_count(), 1);
assert_eq!(panes.focused, 0);
assert_eq!(panes.groups[0].tabs.len(), 4);
assert!(
!panes.move_tab_to_group(-1),
"one group: nothing to move to"
);
panes.reset_weights();
assert_eq!(panes.weights, vec![1]);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn events_route_by_tab_id_and_unknown_ids_are_ignored() {
let (mut panes, _dir) = layout_with(2);
let ids: Vec<TabId> = panes.tabs().map(TerminalTab::id).collect();
assert_eq!(
panes.handle_event(ids[0], TermEvent::Title("t".to_string())),
Some(TabEffect::Redraw)
);
assert_eq!(panes.groups[0].tabs[0].title.as_deref(), Some("t"));
assert!(
panes.groups[1].tabs[0].title.is_none(),
"the other tab is untouched"
);
assert_eq!(panes.handle_event(9999, TermEvent::Wakeup), None);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn arrange_sizes_every_group_and_draw_renders_the_strip() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let (mut panes, _dir) = layout_with(2);
let area = Rect::new(0, 0, 50, 24);
let rects = panes.arrange(area);
assert_eq!(rects.len(), 2);
assert_eq!(rects[0].strip.height, 1);
assert_eq!(rects[1].body.y, rects[0].body.y + rects[0].body.height + 1);
for r in &rects {
assert!(r.grid.width < r.body.width);
assert_eq!(r.grid.y, r.body.y + 1);
}
let mut terminal = Terminal::new(TestBackend::new(50, 24)).unwrap();
terminal
.draw(|frame| {
let rects = panes.arrange(frame.area());
panes.draw(frame, &rects, true);
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(text.contains("1 shell"), "the tab strip names the tab");
let short = panes.arrange(Rect::new(0, 0, 50, 5));
assert_eq!(short.len(), 1);
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
#[cfg(unix)]
#[tokio::test]
async fn find_in_worktree_matches_kind_and_liveness() {
let (mut panes, dir) = layout_with(1);
let here = dir.path().to_path_buf();
assert_eq!(
panes.find_in_worktree(&here, TabKind::Shell),
Some(TabAddr { group: 0, tab: 0 })
);
assert!(panes.find_in_worktree(&here, TabKind::Claude).is_none());
assert!(panes
.find_in_worktree(Path::new("/nowhere"), TabKind::Shell)
.is_none());
panes.groups[0].tabs[0].exit_status = Some(std::process::ExitStatus::default());
assert!(panes.find_in_worktree(&here, TabKind::Shell).is_none());
assert!(!panes.any_alive());
panes.focus(TabAddr { group: 9, tab: 9 });
assert_eq!(panes.focused, 0);
panes.focus(TabAddr { group: 0, tab: 0 });
for tab in panes.groups.iter_mut().flat_map(|g| g.tabs.iter_mut()) {
tab.shutdown();
}
}
}