use super::state::{MissionControlState, TuiLayoutMode};
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TuiPane {
Transcript,
ActivityTree,
Detail,
Prompt,
Status,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TuiLayout {
pub(super) transcript: Rect,
pub(super) activity_tree: Rect,
pub(super) detail: Rect,
pub(super) prompt: Rect,
pub(super) status: Rect,
pub(super) help: Option<Rect>,
}
impl TuiLayout {
pub(crate) fn pane_at(&self, column: u16, row: u16) -> Option<TuiPane> {
if self.help.is_some_and(|area| contains(area, column, row)) {
return Some(TuiPane::Help);
}
[
(TuiPane::Transcript, self.transcript),
(TuiPane::ActivityTree, self.activity_tree),
(TuiPane::Detail, self.detail),
(TuiPane::Prompt, self.prompt),
(TuiPane::Status, self.status),
]
.into_iter()
.find_map(|(pane, area)| contains(area, column, row).then_some(pane))
}
pub(crate) fn pane_area(&self, pane: TuiPane) -> Option<Rect> {
match pane {
TuiPane::Transcript => Some(self.transcript),
TuiPane::ActivityTree => Some(self.activity_tree),
TuiPane::Detail => Some(self.detail),
TuiPane::Prompt => Some(self.prompt),
TuiPane::Status => Some(self.status),
TuiPane::Help => self.help,
}
}
}
pub(crate) fn layout_for(area: Rect, state: &MissionControlState) -> TuiLayout {
let prompt_wrap_width = area.width.saturating_sub(5).max(1);
let prompt_content_rows = state.prompt_visible_rows(prompt_wrap_width);
let prompt_height = prompt_content_rows.saturating_add(2);
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(prompt_height),
Constraint::Length(1),
])
.split(area);
let body_constraints = match state.layout_mode {
TuiLayoutMode::TranscriptFocused => {
[Constraint::Percentage(60), Constraint::Percentage(40)]
}
TuiLayoutMode::ActivityFocused => [Constraint::Percentage(40), Constraint::Percentage(60)],
};
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints(body_constraints)
.split(root[0]);
let activity = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(body[1]);
TuiLayout {
transcript: body[0],
activity_tree: activity[0],
detail: activity[1],
prompt: root[1],
status: root[2],
help: state.show_help.then_some(centered_rect(70, 60, area)),
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let [vertical] = Layout::vertical([Constraint::Percentage(percent_y)])
.flex(Flex::Center)
.areas(area);
let [centered] = Layout::horizontal([Constraint::Percentage(percent_x)])
.flex(Flex::Center)
.areas(vertical);
centered
}
fn contains(area: Rect, column: u16, row: u16) -> bool {
column >= area.x
&& column < area.x.saturating_add(area.width)
&& row >= area.y
&& row < area.y.saturating_add(area.height)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn centered_rect_centers_overlay_within_area() {
let area = Rect::new(0, 0, 100, 30);
let centered = centered_rect(70, 60, area);
assert_eq!(centered.width, 70);
assert_eq!(centered.height, 18);
let left_margin = centered.x - area.x;
let right_margin = area.width - left_margin - centered.width;
let top_margin = centered.y - area.y;
let bottom_margin = area.height - top_margin - centered.height;
assert!(left_margin.abs_diff(right_margin) <= 1);
assert!(top_margin.abs_diff(bottom_margin) <= 1);
}
#[test]
fn layout_body_starts_at_top_without_header_row() {
let state = MissionControlState::default();
let layout = layout_for(Rect::new(0, 0, 100, 30), &state);
assert_eq!(layout.transcript.y, 0);
assert_eq!(layout.activity_tree.y, 0);
assert_eq!(layout.pane_at(0, 0), Some(TuiPane::Transcript));
}
#[test]
fn selected_activity_detail_gets_taller_activity_pane() {
let state = MissionControlState::default();
let layout = layout_for(Rect::new(0, 0, 100, 30), &state);
assert!(layout.detail.height > layout.activity_tree.height);
assert_eq!(
layout
.activity_tree
.y
.saturating_add(layout.activity_tree.height),
layout.detail.y
);
}
}