use ratatui::{
layout::{Position, Rect},
text::Line,
};
use super::{activity, render::display_width, scrollbar::HistoryScrollbar, App, HistoryScroll};
pub(super) const MIN_TERMINAL_WIDTH: u16 = 10;
pub(super) const MIN_TERMINAL_HEIGHT: u16 = 4;
pub(super) fn terminal_meets_minimum(area: Rect) -> bool {
area.width >= MIN_TERMINAL_WIDTH && area.height >= MIN_TERMINAL_HEIGHT
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct BottomChrome {
pub(super) statusline_height: usize,
pub(super) bottom_divider_height: usize,
pub(super) command_height: usize,
}
pub(super) fn bottom_chrome_heights(
height: usize,
desired_statusline_height: usize,
composer_line_count: usize,
command_line_count: usize,
) -> BottomChrome {
let minimum_composer_height = usize::from(composer_line_count > 0);
let statusline_height =
desired_statusline_height.min(height.saturating_sub(minimum_composer_height));
let leftover = height.saturating_sub(minimum_composer_height + statusline_height);
let bottom_divider_height = usize::from(statusline_height > 0 && leftover > 0);
let command_height = command_line_count.min(leftover.saturating_sub(bottom_divider_height));
BottomChrome {
statusline_height,
bottom_divider_height,
command_height,
}
}
fn claim_rows(remaining: &mut usize, desired: usize, floor: usize) -> usize {
let granted = desired.min(remaining.saturating_sub(floor));
*remaining = remaining.saturating_sub(granted);
granted
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct InteractiveSplit {
pending_input: usize,
subagents: usize,
processes: usize,
composer: usize,
history: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct InteractiveBudget {
budget: usize,
composer_lines: usize,
desired_pending: usize,
desired_subagents: usize,
desired_processes: usize,
activity_floor: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ChromeRails {
pub(super) height: usize,
pub(super) desired_statusline_height: usize,
pub(super) composer_line_count: usize,
pub(super) command_line_count: usize,
pub(super) desired_pending: usize,
pub(super) desired_subagents: usize,
pub(super) desired_processes: usize,
pub(super) activity_floor: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct InteractiveChrome {
pub(super) bottom: BottomChrome,
pub(super) show_top_divider: bool,
split: InteractiveSplit,
}
impl InteractiveChrome {
pub(super) fn history_height(self) -> usize {
self.split.history
}
}
pub(super) fn interactive_chrome(rails: ChromeRails) -> InteractiveChrome {
let bottom = bottom_chrome_heights(
rails.height,
rails.desired_statusline_height,
rails.composer_line_count,
rails.command_line_count,
);
let bottom_fixed_height =
bottom.bottom_divider_height + bottom.statusline_height + bottom.command_height;
let available_above_bottom = rails.height.saturating_sub(bottom_fixed_height);
let show_top_divider = available_above_bottom > 1 && rails.composer_line_count > 0;
let interactive_budget = available_above_bottom.saturating_sub(usize::from(show_top_divider));
let split = split_interactive_budget(InteractiveBudget {
budget: interactive_budget,
composer_lines: rails.composer_line_count,
desired_pending: rails.desired_pending,
desired_subagents: rails.desired_subagents,
desired_processes: rails.desired_processes,
activity_floor: rails.activity_floor,
});
InteractiveChrome {
bottom,
show_top_divider,
split,
}
}
fn split_interactive_budget(input: InteractiveBudget) -> InteractiveSplit {
let composer_floor = usize::from(input.composer_lines > 0);
let keep = composer_floor + input.activity_floor;
let mut remaining = input.budget;
let pending_reserve = claim_rows(&mut remaining, input.desired_pending.min(2), keep);
let subagents = claim_rows(&mut remaining, input.desired_subagents, keep);
let processes = claim_rows(&mut remaining, input.desired_processes, keep);
let composer = claim_rows(&mut remaining, input.composer_lines, input.activity_floor);
remaining = remaining.saturating_add(pending_reserve);
let pending_input = claim_rows(&mut remaining, input.desired_pending, input.activity_floor);
InteractiveSplit {
pending_input,
subagents,
processes,
composer,
history: remaining,
}
}
pub(super) fn visible_composer_start(
cursor_line: usize,
line_count: usize,
visible_count: usize,
current_start: usize,
) -> usize {
if visible_count == 0 || visible_count >= line_count {
return 0;
}
let max_start = line_count.saturating_sub(visible_count);
let current_start = current_start.min(max_start);
if cursor_line < current_start {
cursor_line
} else if cursor_line >= current_start.saturating_add(visible_count) {
cursor_line
.saturating_add(1)
.saturating_sub(visible_count)
.min(max_start)
} else {
current_start
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ScreenLayout {
pub(super) history: Rect,
pub(super) history_content: Rect,
pub(super) history_scrollbar: Option<HistoryScrollbar>,
pub(super) activity_gap: Option<Rect>,
pub(super) activity_rail: Option<Rect>,
pub(super) jump_to_bottom: Option<Rect>,
pub(super) pending_input: Rect,
pub(super) subagents: Rect,
pub(super) processes: Rect,
pub(super) top_divider: Rect,
pub(super) composer: Rect,
pub(super) bottom_divider: Rect,
pub(super) statusline: Rect,
pub(super) commands: Rect,
pub(super) composer_start: usize,
pub(super) history_len: usize,
}
impl ScreenLayout {
pub(super) fn activity_label_width(&self) -> Option<usize> {
let rail = self.activity_rail?;
Some(self.jump_to_bottom.map_or(rail.width as usize, |jump| {
(rail.width as usize).saturating_sub(jump.width as usize + 1)
}))
}
}
impl App {
pub(super) fn build_screen_layout(
&mut self,
area: Rect,
history_len: usize,
composer_lines: &[Line<'_>],
composer_cursor: Position,
chrome: InteractiveChrome,
) -> ScreenLayout {
let width = area.width as usize;
let cursor_line = (composer_cursor.y as usize).min(composer_lines.len().saturating_sub(1));
let statusline_height = chrome.bottom.statusline_height;
let bottom_divider_height = chrome.bottom.bottom_divider_height;
let command_height = chrome.bottom.command_height;
let show_top_divider = chrome.show_top_divider;
let split = chrome.split;
let history_height_without_jump = split.history;
let content_height_without_jump = self.history_content_height(history_height_without_jump);
let show_jump_to_bottom = content_height_without_jump > 0
&& self.visible_history_start(history_len, content_height_without_jump)
< history_len.saturating_sub(content_height_without_jump);
let visible_composer_len = split.composer;
let composer_start = visible_composer_start(
cursor_line,
composer_lines.len(),
visible_composer_len,
self.input_ui.composer_view_start(),
);
self.input_ui.set_composer_view_start(composer_start);
let pending_input_height = split.pending_input;
let subagent_height = split.subagents;
let process_height = split.processes;
let history_height = split.history;
let mut y = area.y;
let history = Rect::new(area.x, y, area.width, history_height as u16);
y = y.saturating_add(history.height);
let activity_status = self.activity_status();
let activity_active = activity_status.is_some() && history.height > 0;
let bottom_follow = matches!(self.history.scroll(), HistoryScroll::Bottom);
let content_inset = activity::bottom_follow_activity_inset(activity_active, bottom_follow)
.min(history_height);
let content_height = history_height.saturating_sub(content_inset);
let history_content = Rect::new(history.x, history.y, history.width, content_height as u16);
let activity_y = history.bottom().saturating_sub(1);
let activity_gap = (content_inset
>= activity::ACTIVITY_RAIL_ROWS + activity::ACTIVITY_CONTENT_GAP_ROWS)
.then(|| {
Rect::new(
history.x,
activity_y.saturating_sub(activity::ACTIVITY_CONTENT_GAP_ROWS as u16),
history.width,
activity::ACTIVITY_CONTENT_GAP_ROWS as u16,
)
});
let jump_text =
show_jump_to_bottom.then(|| self.jump_to_bottom_text(width, self.jump_chip_state()));
let jump_width = jump_text.as_deref().map_or(0, display_width).min(width) as u16;
let jump_to_bottom = jump_text.map(|_| {
Rect::new(
history
.x
.saturating_add(history.width.saturating_sub(jump_width)),
activity_y,
jump_width,
1,
)
});
let activity_rail = (activity_status.is_some() && history.height > 0)
.then(|| Rect::new(history.x, activity_y, history.width, 1));
let pending_input = Rect::new(area.x, y, area.width, pending_input_height as u16);
y = y.saturating_add(pending_input.height);
let subagents = Rect::new(area.x, y, area.width, subagent_height as u16);
y = y.saturating_add(subagents.height);
let processes = Rect::new(area.x, y, area.width, process_height as u16);
y = y.saturating_add(processes.height);
let top_divider = if show_top_divider {
let rect = Rect::new(area.x, y, area.width, 1);
y = y.saturating_add(1);
rect
} else {
Rect::new(area.x, y, area.width, 0)
};
let commands = Rect::new(area.x, y, area.width, command_height as u16);
y = y.saturating_add(commands.height);
let composer = Rect::new(area.x, y, area.width, visible_composer_len as u16);
y = y.saturating_add(composer.height);
let bottom_divider = Rect::new(area.x, y, area.width, bottom_divider_height as u16);
y = y.saturating_add(bottom_divider.height);
let statusline = Rect::new(area.x, y, area.width, statusline_height as u16);
ScreenLayout {
history,
history_content,
history_scrollbar: HistoryScrollbar::new(
history_content,
history_len,
self.visible_history_start(history_len, content_height),
),
activity_gap,
activity_rail,
jump_to_bottom,
pending_input,
subagents,
processes,
top_divider,
composer,
bottom_divider,
statusline,
commands,
composer_start,
history_len,
}
}
pub(super) fn history_content_inset(&self) -> usize {
activity::bottom_follow_activity_inset(
self.activity_status().is_some(),
matches!(self.history.scroll(), HistoryScroll::Bottom),
)
}
pub(super) fn history_content_height(&self, panel_height: usize) -> usize {
panel_height.saturating_sub(self.history_content_inset().min(panel_height))
}
}
#[cfg(test)]
#[path = "screen_layout_tests.rs"]
mod tests;