use ratatui::layout::{Constraint, Direction, Rect};
const INPUT_HEIGHT: u16 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SplitDirection {
#[default]
Horizontal, Vertical, }
impl SplitDirection {
pub fn toggle(self) -> Self {
match self {
Self::Horizontal => Self::Vertical,
Self::Vertical => Self::Horizontal,
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::Horizontal => "H",
Self::Vertical => "V",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
#[default]
Both,
AgentsOnly,
PreviewOnly,
}
impl ViewMode {
pub fn display_name(&self) -> &'static str {
match self {
ViewMode::Both => "Split",
ViewMode::AgentsOnly => "List",
ViewMode::PreviewOnly => "Preview",
}
}
}
const SPLIT_STEP: u16 = 10;
pub struct Layout {
pub split_offset: u16,
pub split_direction: SplitDirection,
pub input_height: u16,
}
impl Layout {
pub fn new() -> Self {
Self {
split_offset: 60,
split_direction: SplitDirection::default(),
input_height: INPUT_HEIGHT,
}
}
pub fn with_split_offset(mut self, offset: u16) -> Self {
self.split_offset = offset.clamp(0, 100);
self
}
pub fn step_split_offset_down(&mut self) -> u16 {
self.split_offset = if self.split_offset == 0 {
100
} else {
self.split_offset.saturating_sub(SPLIT_STEP)
};
self.split_offset
}
pub fn step_split_offset_up(&mut self) -> u16 {
self.split_offset = if self.split_offset >= 100 {
0
} else {
(self.split_offset + SPLIT_STEP).min(100)
};
self.split_offset
}
pub fn view_mode(&self) -> ViewMode {
match self.split_offset {
0 => ViewMode::AgentsOnly,
100 => ViewMode::PreviewOnly,
_ => ViewMode::Both,
}
}
pub fn toggle_split_direction(&mut self) {
self.split_direction = self.split_direction.toggle();
}
pub fn split_direction(&self) -> SplitDirection {
self.split_direction
}
fn list_pct(&self) -> u16 {
100 - self.split_offset
}
pub fn set_input_height(&mut self, height: u16) {
self.input_height = height.max(3);
}
pub fn calculate(&self, area: Rect) -> LayoutAreas {
self.calculate_with_input(area, false)
}
pub fn calculate_with_input(&self, area: Rect, show_input: bool) -> LayoutAreas {
let main_and_status = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), Constraint::Length(1), ])
.split(area);
let main_area = main_and_status[0];
let status_bar = main_and_status[1];
match self.view_mode() {
ViewMode::Both => match self.split_direction {
SplitDirection::Horizontal => {
let horizontal = ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(self.list_pct()),
Constraint::Percentage(self.split_offset),
])
.split(main_area);
if show_input {
let right_panel = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(self.input_height),
])
.split(horizontal[1]);
LayoutAreas {
session_list: Some(horizontal[0]),
preview: Some(right_panel[0]),
input: Some(right_panel[1]),
status_bar,
split_direction: self.split_direction,
}
} else {
LayoutAreas {
session_list: Some(horizontal[0]),
preview: Some(horizontal[1]),
input: None,
status_bar,
split_direction: self.split_direction,
}
}
}
SplitDirection::Vertical => {
let vertical = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(self.list_pct()),
Constraint::Percentage(self.split_offset),
])
.split(main_area);
if show_input {
let bottom_panel = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(self.input_height),
])
.split(vertical[1]);
LayoutAreas {
session_list: Some(vertical[0]),
preview: Some(bottom_panel[0]),
input: Some(bottom_panel[1]),
status_bar,
split_direction: self.split_direction,
}
} else {
LayoutAreas {
session_list: Some(vertical[0]),
preview: Some(vertical[1]),
input: None,
status_bar,
split_direction: self.split_direction,
}
}
}
},
ViewMode::AgentsOnly => {
LayoutAreas {
session_list: Some(main_area),
preview: None,
input: None,
status_bar,
split_direction: self.split_direction,
}
}
ViewMode::PreviewOnly => {
if show_input {
let vertical = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(self.input_height)])
.split(main_area);
LayoutAreas {
session_list: None,
preview: Some(vertical[0]),
input: Some(vertical[1]),
status_bar,
split_direction: self.split_direction,
}
} else {
LayoutAreas {
session_list: None,
preview: Some(main_area),
input: None,
status_bar,
split_direction: self.split_direction,
}
}
}
}
}
pub fn popup_area(&self, area: Rect, width_pct: u16, height_pct: u16) -> Rect {
let popup_layout = ratatui::layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_pct) / 2),
Constraint::Percentage(height_pct),
Constraint::Percentage((100 - height_pct) / 2),
])
.split(area);
ratatui::layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_pct) / 2),
Constraint::Percentage(width_pct),
Constraint::Percentage((100 - width_pct) / 2),
])
.split(popup_layout[1])[1]
}
}
impl Default for Layout {
fn default() -> Self {
Self::new()
}
}
pub struct LayoutAreas {
pub session_list: Option<Rect>,
pub preview: Option<Rect>,
pub input: Option<Rect>,
pub status_bar: Rect,
pub split_direction: SplitDirection,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_layout_calculation() {
let layout = Layout::new();
let area = Rect::new(0, 0, 100, 50);
let areas = layout.calculate(area);
assert!(areas.session_list.is_some());
assert!(areas.preview.is_some());
assert!(areas.input.is_none());
assert_eq!(areas.status_bar.height, 1);
}
#[test]
fn test_layout_with_input() {
let layout = Layout::new();
let area = Rect::new(0, 0, 100, 50);
let areas = layout.calculate_with_input(area, true);
assert!(areas.session_list.is_some());
assert!(areas.preview.is_some());
assert!(areas.input.is_some());
assert_eq!(areas.input.unwrap().height, 3);
}
#[test]
fn test_layout_agents_only() {
let layout = Layout::new().with_split_offset(0);
let area = Rect::new(0, 0, 100, 50);
let areas = layout.calculate(area);
assert!(areas.session_list.is_some());
assert!(areas.preview.is_none());
}
#[test]
fn test_layout_preview_only() {
let layout = Layout::new().with_split_offset(100);
let area = Rect::new(0, 0, 100, 50);
let areas = layout.calculate(area);
assert!(areas.session_list.is_none());
assert!(areas.preview.is_some());
}
#[test]
fn test_step_split_offset_down() {
let mut layout = Layout::new().with_split_offset(60);
assert_eq!(layout.view_mode(), ViewMode::Both);
layout.step_split_offset_down();
assert_eq!(layout.split_offset, 50);
for _ in 0..5 {
layout.step_split_offset_down();
}
assert_eq!(layout.split_offset, 0);
assert_eq!(layout.view_mode(), ViewMode::AgentsOnly);
layout.step_split_offset_down();
assert_eq!(layout.split_offset, 100);
assert_eq!(layout.view_mode(), ViewMode::PreviewOnly);
}
#[test]
fn test_step_split_offset_up() {
let mut layout = Layout::new().with_split_offset(40);
layout.step_split_offset_up();
assert_eq!(layout.split_offset, 50);
for _ in 0..5 {
layout.step_split_offset_up();
}
assert_eq!(layout.split_offset, 100);
assert_eq!(layout.view_mode(), ViewMode::PreviewOnly);
layout.step_split_offset_up();
assert_eq!(layout.split_offset, 0);
assert_eq!(layout.view_mode(), ViewMode::AgentsOnly);
}
#[test]
fn test_popup_area() {
let layout = Layout::new();
let area = Rect::new(0, 0, 100, 50);
let popup = layout.popup_area(area, 60, 40);
assert!(popup.x > 0);
assert!(popup.y > 0);
assert!(popup.x + popup.width < area.width);
assert!(popup.y + popup.height < area.height);
}
#[test]
fn test_split_direction_toggle() {
let mut layout = Layout::new();
assert_eq!(layout.split_direction, SplitDirection::Horizontal);
layout.toggle_split_direction();
assert_eq!(layout.split_direction, SplitDirection::Vertical);
layout.toggle_split_direction();
assert_eq!(layout.split_direction, SplitDirection::Horizontal);
}
#[test]
fn test_vertical_split_layout() {
let mut layout = Layout::new();
layout.split_direction = SplitDirection::Vertical;
let area = Rect::new(0, 0, 100, 50);
let areas = layout.calculate(area);
assert!(areas.session_list.is_some());
assert!(areas.preview.is_some());
assert_eq!(areas.split_direction, SplitDirection::Vertical);
let session_area = areas.session_list.unwrap();
let preview_area = areas.preview.unwrap();
assert!(session_area.y < preview_area.y);
}
#[test]
fn test_with_split_offset_clamp() {
let layout = Layout::new().with_split_offset(150);
assert_eq!(layout.split_offset, 100);
let layout = Layout::new().with_split_offset(0);
assert_eq!(layout.split_offset, 0);
}
}