use std::time::Instant;
use ratatui::{layout::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,
}
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) activity: 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 App {
pub(super) fn screen_layout(&mut self, area: Rect, now: Instant) -> ScreenLayout {
let width = area.width as usize;
self.note_terminal_geometry(width, area.height as usize);
self.refresh_composer_attachment_layout_cache(width);
let history_len = self.history_len(width, now);
let composer_lines = self.composer_lines(width, area.height as usize);
let command_lines = self.command_suggestion_lines(width);
self.screen_layout_for_history_len(area, history_len, &composer_lines, command_lines.len())
}
pub(super) fn screen_layout_for_history_len(
&mut self,
area: Rect,
history_len: usize,
composer_lines: &[Line<'_>],
command_line_count: usize,
) -> ScreenLayout {
let width = area.width as usize;
let height = area.height as usize;
let full_cursor = self.composer_cursor_position(width);
let cursor_line = (full_cursor.y as usize).min(composer_lines.len().saturating_sub(1));
let chrome = bottom_chrome_heights(
height,
self.statusline.height(),
composer_lines.len(),
command_line_count,
);
let statusline_height = chrome.statusline_height;
let bottom_divider_height = chrome.bottom_divider_height;
let command_height = chrome.command_height;
let bottom_fixed_height = bottom_divider_height + statusline_height + command_height;
let available_above_bottom = height.saturating_sub(bottom_fixed_height);
let show_top_divider = available_above_bottom > 1 && !composer_lines.is_empty();
let history_height_without_jump =
self.history_height_from_line_counts(height, composer_lines.len(), command_line_count);
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 reserved_above_composer = usize::from(show_top_divider);
let interactive_budget = available_above_bottom.saturating_sub(reserved_above_composer);
let split = split_interactive_budget(InteractiveBudget {
budget: interactive_budget,
composer_lines: composer_lines.len(),
desired_pending: self.pending_input_height(),
desired_subagents: self.subagent_panel.desired_height(),
desired_processes: self.process_panel.desired_height(),
activity_floor: usize::from(
self.subagent_panel.is_active() || self.process_panel.is_active(),
),
});
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));
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_available = if jump_width > 0 {
width.saturating_sub(jump_width as usize + 1)
} else {
width
};
let activity_width = activity_status
.map(|status| activity::activity_width(activity_available, status))
.unwrap_or(0) as u16;
let activity = (activity_width > 0 && history.height > 0)
.then(|| Rect::new(history.x, activity_y, activity_width, 1));
let activity_rail = activity.map(|_| 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,
activity,
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))
}
pub(super) fn history_height_for_screen(
&self,
width: usize,
height: usize,
_now: Instant,
) -> usize {
self.history_height_from_line_counts(
height,
self.composer_lines(width, height).len(),
self.command_suggestion_lines(width).len(),
)
}
pub(super) fn history_content_height_for_screen(
&self,
width: usize,
height: usize,
now: Instant,
) -> usize {
self.history_content_height(self.history_height_for_screen(width, height, now))
}
pub(super) fn history_height_from_line_counts(
&self,
height: usize,
composer_line_count: usize,
command_line_count: usize,
) -> usize {
let chrome = bottom_chrome_heights(
height,
self.statusline.height(),
composer_line_count,
command_line_count,
);
let statusline_height = chrome.statusline_height;
let bottom_divider_height = chrome.bottom_divider_height;
let command_height = chrome.command_height;
let bottom_fixed_height = bottom_divider_height + statusline_height + command_height;
let available_above_bottom = height.saturating_sub(bottom_fixed_height);
let show_top_divider = available_above_bottom > 1 && composer_line_count > 0;
let reserved_above_composer = usize::from(show_top_divider);
let interactive_budget = available_above_bottom.saturating_sub(reserved_above_composer);
split_interactive_budget(InteractiveBudget {
budget: interactive_budget,
composer_lines: composer_line_count,
desired_pending: self.pending_input_height(),
desired_subagents: self.subagent_panel.desired_height(),
desired_processes: self.process_panel.desired_height(),
activity_floor: usize::from(
self.subagent_panel.is_active() || self.process_panel.is_active(),
),
})
.history
}
}
#[cfg(test)]
#[path = "screen_layout_tests.rs"]
mod tests;