use super::state::{MissionControlState, RightColumnTab, TuiLayoutMode};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
pub(crate) const RIGHT_COLUMN_RAIL_WIDTH: u16 = 5;
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,
RightColumnTab::Diff,
]
.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?;
if rail.height < right_column_rail_height() {
let index = match tab {
RightColumnTab::Activity => 0,
RightColumnTab::Summary => 1,
RightColumnTab::Diff => 2,
};
return (index < rail.height).then(|| Rect::new(rail.x, rail.y + index, rail.width, 1));
}
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),
RightColumnTab::Diff => {
rail.y
+ right_column_tab_height(RightColumnTab::Activity)
+ right_column_tab_height(RightColumnTab::Summary)
+ RIGHT_COLUMN_RAIL_SEPARATOR_ROWS * 2
}
};
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,
RightColumnTab::Diff => "Diff",
}
}
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 * 2)
.saturating_add(right_column_tab_height(RightColumnTab::Diff))
}
fn right_column_rail_usable(area: Rect) -> bool {
area.width >= RIGHT_COLUMN_RAIL_WIDTH + 20 && area.height >= 3
}
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 < 20 {
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(3), Constraint::Length(prompt_height)])
.split(area);
if state.right_column_tab == RightColumnTab::Diff {
let rail_width = RIGHT_COLUMN_RAIL_WIDTH.min(root[0].width);
let transcript = Rect::new(
root[0].x,
root[0].y,
root[0].width.saturating_sub(rail_width),
root[0].height,
);
return TuiLayout {
transcript,
activity_tree: None,
detail: None,
summary: None,
right_column_rail: Some(Rect::new(
transcript.right(),
root[0].y,
rail_width,
root[0].height,
)),
prompt: root[1],
help: state
.show_help
.then_some(super::modal_container::modal_area(area, 60, 60)),
};
}
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),
RightColumnTab::Diff => unreachable!("diff layout returned above"),
}
} 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(super::modal_container::modal_area(area, 60, 60)),
}
}
fn non_empty(area: Rect) -> Option<Rect> {
(area.width > 0 && area.height > 0).then_some(area)
}
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 help_geometry_centers_overlay_and_matches_hit_testing() {
let area = Rect::new(3, 2, 100, 30);
let mut state = MissionControlState {
show_help: true,
..Default::default()
};
for tab in [RightColumnTab::Activity, RightColumnTab::Diff] {
state.right_column_tab = tab;
let layout = layout_for(area, &state);
let help = layout.help.expect("help");
assert_eq!(help, Rect::new(23, 8, 60, 18));
assert_eq!(layout.pane_at(help.x, help.y), Some(TuiPane::Help));
assert_ne!(layout.pane_at(help.x - 1, help.y), Some(TuiPane::Help));
}
}
#[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_keeps_three_tabs_on_compact_screens() {
assert!(!right_column_rail_usable(Rect::new(
0,
0,
RIGHT_COLUMN_RAIL_WIDTH,
3
)));
assert!(!right_column_rail_usable(Rect::new(0, 0, 20, 2)));
assert!(!right_column_rail_usable(Rect::new(0, 0, 20, 3)));
assert!(right_column_rail_usable(Rect::new(0, 0, 25, 3)));
for area in [Rect::new(0, 0, 80, 30), Rect::new(0, 0, 100, 14)] {
let layout = layout_for(area, &MissionControlState::default());
let tab = layout
.right_column_tab_area(RightColumnTab::Diff)
.expect("Diff stays reachable");
assert!(tab.bottom() <= layout.prompt.y);
}
}
#[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)] {
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);
}
}
}
}