use super::state::{MissionControlState, RightColumnTab, TuiLayoutMode};
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
pub(crate) const RIGHT_COLUMN_RAIL_WIDTH: u16 = 5;
pub(crate) const MIN_RIGHT_COLUMN_CONTENT_WIDTH: u16 = 20;
const ACTIVITY_RAIL_LABEL: &str = "Activity";
const SUMMARY_RAIL_LABEL: &str = "Summary";
pub(crate) const RIGHT_COLUMN_TAB_VERTICAL_PADDING: u16 = 1;
const RIGHT_COLUMN_RAIL_SEPARATOR_ROWS: u16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TuiPane {
Transcript,
ActivityTree,
Detail,
Summary,
RightColumnRail,
Prompt,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TuiLayout {
pub(super) transcript: Rect,
pub(super) activity_tree: Option<Rect>,
pub(super) detail: Option<Rect>,
pub(super) summary: Option<Rect>,
pub(super) right_column_rail: Option<Rect>,
pub(super) prompt: 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, Some(self.transcript)),
(TuiPane::ActivityTree, self.activity_tree),
(TuiPane::Detail, self.detail),
(TuiPane::Summary, self.summary),
(TuiPane::RightColumnRail, self.right_column_rail),
(TuiPane::Prompt, Some(self.prompt)),
]
.into_iter()
.find_map(|(pane, area)| {
area.filter(|area| contains(*area, column, row))
.map(|_| pane)
})
}
pub(crate) fn pane_area(&self, pane: TuiPane) -> Option<Rect> {
match pane {
TuiPane::Transcript => Some(self.transcript),
TuiPane::ActivityTree => self.activity_tree,
TuiPane::Detail => self.detail,
TuiPane::Summary => self.summary,
TuiPane::RightColumnRail => self.right_column_rail,
TuiPane::Prompt => Some(self.prompt),
TuiPane::Help => self.help,
}
}
pub(crate) fn right_column_tab_at(&self, column: u16, row: u16) -> Option<RightColumnTab> {
[RightColumnTab::Activity, RightColumnTab::Summary]
.into_iter()
.find(|&tab| {
self.right_column_tab_area(tab)
.is_some_and(|area| contains(area, column, row))
})
}
pub(crate) fn right_column_tab_area(&self, tab: RightColumnTab) -> Option<Rect> {
let rail = self.right_column_rail?;
let y = match tab {
RightColumnTab::Activity => rail.y,
RightColumnTab::Summary => rail
.y
.saturating_add(right_column_tab_height(RightColumnTab::Activity))
.saturating_add(RIGHT_COLUMN_RAIL_SEPARATOR_ROWS),
};
Some(Rect::new(
rail.x,
y,
rail.width,
right_column_tab_height(tab),
))
}
}
pub(crate) fn right_column_tab_label(tab: RightColumnTab) -> &'static str {
match tab {
RightColumnTab::Activity => ACTIVITY_RAIL_LABEL,
RightColumnTab::Summary => SUMMARY_RAIL_LABEL,
}
}
fn right_column_tab_height(tab: RightColumnTab) -> u16 {
(right_column_tab_label(tab).chars().count() as u16)
.saturating_add(RIGHT_COLUMN_TAB_VERTICAL_PADDING.saturating_mul(2))
.saturating_add(u16::from(tab == RightColumnTab::Summary))
}
fn right_column_rail_height() -> u16 {
right_column_tab_height(RightColumnTab::Activity)
.saturating_add(right_column_tab_height(RightColumnTab::Summary))
.saturating_add(RIGHT_COLUMN_RAIL_SEPARATOR_ROWS)
}
fn right_column_rail_usable(area: Rect) -> bool {
area.width >= RIGHT_COLUMN_RAIL_WIDTH.saturating_add(MIN_RIGHT_COLUMN_CONTENT_WIDTH)
&& area.height >= right_column_rail_height()
}
fn split_body(area: Rect, constraints: [Constraint; 2]) -> [Rect; 2] {
Layout::horizontal(constraints).areas(area)
}
fn split_body_with_optional_rail(
area: Rect,
constraints: [Constraint; 2],
) -> ([Rect; 2], Option<Rect>) {
let body = split_body(area, constraints);
if !right_column_rail_usable(body[1]) {
return (body, None);
}
let content_area = Rect::new(
area.x,
area.y,
area.width.saturating_sub(RIGHT_COLUMN_RAIL_WIDTH),
area.height,
);
let body = split_body(content_area, constraints);
if body[1].width < MIN_RIGHT_COLUMN_CONTENT_WIDTH {
return (split_body(area, constraints), None);
}
let rail = Rect::new(
body[1].x.saturating_add(body[1].width),
area.y,
RIGHT_COLUMN_RAIL_WIDTH,
area.height,
);
(body, Some(rail))
}
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)])
.split(area);
let (transcript, activity_tree, detail, summary, right_column_rail) =
if state.activity_column_visible() {
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, rail) = split_body_with_optional_rail(root[0], body_constraints);
let right_content = body[1];
match state.right_column_tab {
RightColumnTab::Activity => {
let activity = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(right_content);
(
body[0],
non_empty(activity[0]),
non_empty(activity[1]),
None,
rail,
)
}
RightColumnTab::Summary => (body[0], None, None, non_empty(right_content), rail),
}
} else {
(root[0], None, None, None, None)
};
TuiLayout {
transcript,
activity_tree,
detail,
summary,
right_column_rail,
prompt: root[1],
help: state.show_help.then_some(centered_rect(70, 60, area)),
}
}
fn non_empty(area: Rect) -> Option<Rect> {
(area.width > 0 && area.height > 0).then_some(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 prompt_reaches_bottom_and_panels_use_all_remaining_rows() {
let area = Rect::new(3, 2, 100, 30);
let mut state = MissionControlState::default();
for tab in [RightColumnTab::Activity, RightColumnTab::Summary] {
state.right_column_tab = tab;
let layout = layout_for(area, &state);
assert_eq!(layout.prompt.bottom(), area.bottom());
assert_eq!(layout.transcript.bottom(), layout.prompt.y);
assert_eq!(layout.transcript.height, area.height - layout.prompt.height);
assert_eq!(
layout.pane_at(area.x, area.bottom() - 1),
Some(TuiPane::Prompt)
);
let right_bottom = layout.summary.or(layout.detail).expect("right panel");
assert_eq!(right_bottom.bottom(), layout.prompt.y);
assert_eq!(
layout.right_column_rail.expect("rail").bottom(),
layout.prompt.y
);
}
state.toggle_activity_column();
let layout = layout_for(area, &state);
assert_eq!(layout.prompt.bottom(), area.bottom());
assert_eq!(layout.transcript.bottom(), layout.prompt.y);
}
#[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.expect("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);
let activity_tree = layout.activity_tree.expect("activity tree");
let detail = layout.detail.expect("detail");
assert!(detail.height > activity_tree.height);
assert_eq!(
activity_tree.y.saturating_add(activity_tree.height),
detail.y
);
}
#[test]
fn right_column_rail_requires_minimum_content_and_label_height() {
let width = RIGHT_COLUMN_RAIL_WIDTH + MIN_RIGHT_COLUMN_CONTENT_WIDTH;
let height = right_column_rail_height();
assert!(!right_column_rail_usable(Rect::new(
0,
0,
width - 1,
height
)));
assert!(right_column_rail_usable(Rect::new(0, 0, width, height)));
assert!(!right_column_rail_usable(Rect::new(
0,
0,
width,
height - 1
)));
}
#[test]
fn right_column_rail_falls_back_for_short_zero_and_tiny_layouts() {
let state = MissionControlState::default();
for area in [
Rect::new(0, 0, 0, 0),
Rect::new(0, 0, 3, 30),
Rect::new(0, 0, 40, 30),
Rect::new(0, 0, 100, 14),
] {
assert_eq!(
layout_for(area, &state).right_column_rail,
None,
"rail should be omitted for {area:?}"
);
}
assert!(
layout_for(Rect::new(0, 0, 100, 30), &state)
.right_column_rail
.is_some()
);
}
#[test]
fn hidden_activity_column_uses_full_body_and_restores_widths() {
let area = Rect::new(0, 0, 100, 30);
let mut state = MissionControlState::default();
state.focus_activity_layout();
let visible = layout_for(area, &state);
let visible_transcript_width = visible.transcript.width;
let visible_activity_width = visible.activity_tree.expect("activity tree").width;
state.toggle_activity_column();
let hidden = layout_for(area, &state);
assert_eq!(
hidden.transcript,
Rect::new(0, 0, 100, hidden.transcript.height)
);
assert_eq!(hidden.activity_tree, None);
assert_eq!(hidden.detail, None);
assert_eq!(hidden.pane_area(TuiPane::ActivityTree), None);
assert_eq!(hidden.pane_area(TuiPane::Detail), None);
assert_eq!(hidden.pane_at(99, 4), Some(TuiPane::Transcript));
state.toggle_activity_column();
let restored = layout_for(area, &state);
assert_eq!(restored.transcript.width, visible_transcript_width);
assert_eq!(
restored.activity_tree.expect("activity tree").width,
visible_activity_width
);
}
#[test]
fn hidden_activity_column_is_safe_for_tiny_terminals() {
let mut state = MissionControlState::default();
state.toggle_activity_column();
for width in 0..=4 {
for height in 0..=4 {
let layout = layout_for(Rect::new(0, 0, width, height), &state);
assert_eq!(layout.activity_tree, None);
assert_eq!(layout.detail, None);
}
}
}
}