//! The console frame: body, command-log pane, footer. //! //! sysop-tui split a screen into body plus a one-row footer. Alloy adds the //! command-log pane between them, which docs/CONSOLE.md settled as always-on — //! the "console teaches its own primitives" claim only lands if the log of //! underlying CLI invocations is visible without being asked for. use ratatui::layout::{Constraint, Layout, Rect}; /// Default height of the command-log pane, borders included: a two-row window /// onto the log plus its box. Enough to show the command just run and the one /// before it, which is what makes the pane read as a running transcript rather /// than a status line. pub const LOG_HEIGHT: u16 = 4; /// Below this total height the log pane is dropped entirely. The body needs /// room to be worth drawing; on a short terminal the log is the first thing /// that should go, and it degrades to nothing rather than to a sliver of /// borders with no content between them. const MIN_HEIGHT_FOR_LOG: u16 = 12; /// The three regions of a console screen. `log` is empty (zero height) when /// the terminal is too short to carry it — callers can render into it /// unconditionally, since ratatui clips a zero-area render. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConsoleAreas { pub body: Rect, pub log: Rect, pub footer: Rect, } /// Split a full-screen area into body, log pane, and footer. pub fn console(area: Rect) -> ConsoleAreas { console_with_log_height(area, LOG_HEIGHT) } /// As [`console`], with an explicit log-pane height. pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas { if area.height < MIN_HEIGHT_FOR_LOG || log_height == 0 { let [body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area); return ConsoleAreas { body, log: Rect { height: 0, ..body }, footer, }; } let [body, log, footer] = Layout::vertical([ Constraint::Min(0), Constraint::Length(log_height), Constraint::Length(1), ]) .areas(area); ConsoleAreas { body, log, footer } } /// Width of the gutter between two linked panes. Three columns is the minimum /// an elbow needs: one to leave the left pane, one to carry the vertical run, /// one to enter the right pane. pub const GUTTER_WIDTH: u16 = 3; /// Center a `width` x `height` box inside `area`, for a modal drawn over a /// view. /// /// Clamps rather than overflowing: a box larger than the area it sits in /// becomes the area. A modal that renders partly offscreen is worse than a /// cramped one, because the keys that dismiss it are listed on its last row. pub fn centered(area: Rect, width: u16, height: u16) -> Rect { let width = width.min(area.width); let height = height.min(area.height); Rect { x: area.x + (area.width - width) / 2, y: area.y + (area.height - height) / 2, width, height, } } /// Two panes with a connector gutter between them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneAreas { pub left: Rect, pub gutter: Rect, pub right: Rect, } /// Split a body area into two panes separated by a connector gutter. /// /// The split is even, with any odd column going to the left pane. Below the /// width needed for two usable panes the gutter collapses and the right pane /// takes zero width; callers render into it unconditionally, since ratatui /// clips a zero-area render, and a view that wants different narrow-terminal /// behavior can check `right.width`. pub fn panes(area: Rect) -> PaneAreas { // Two panes of at least this width each, plus the gutter, or the split is // not worth making: below it a pane is too narrow to hold a label. const MIN_PANE_WIDTH: u16 = 16; if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH { return PaneAreas { left: area, gutter: Rect { width: 0, ..area }, right: Rect { width: 0, ..area }, }; } let usable = area.width - GUTTER_WIDTH; let left_width = usable - usable / 2; PaneAreas { left: Rect { width: left_width, ..area }, gutter: Rect { x: area.x + left_width, width: GUTTER_WIDTH, ..area }, right: Rect { x: area.x + left_width + GUTTER_WIDTH, width: usable / 2, ..area }, } } #[cfg(test)] mod tests { use super::*; #[test] fn panes_tile_the_area_exactly() { let areas = panes(Rect::new(0, 0, 80, 20)); assert_eq!(areas.left.x, 0); assert_eq!(areas.gutter.x, areas.left.x + areas.left.width); assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width); assert_eq!( areas.left.width + areas.gutter.width + areas.right.width, 80, "no column is unaccounted for" ); assert_eq!(areas.gutter.width, GUTTER_WIDTH); } // An odd usable width cannot split evenly; the extra column has to go // somewhere deterministic rather than being dropped. #[test] fn odd_widths_give_the_extra_column_to_the_left() { let areas = panes(Rect::new(0, 0, 81, 20)); assert_eq!(areas.left.width, 39); assert_eq!(areas.right.width, 39); assert_eq!( areas.left.width + areas.gutter.width + areas.right.width, 81 ); } // A narrow terminal collapses to one pane rather than two unusable slivers. #[test] fn narrow_area_collapses_to_a_single_pane() { let areas = panes(Rect::new(0, 0, 30, 20)); assert_eq!(areas.left.width, 30); assert_eq!(areas.right.width, 0); assert_eq!(areas.gutter.width, 0); } #[test] fn full_height_gets_all_three_regions() { let areas = console(Rect::new(0, 0, 80, 24)); assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1); assert_eq!(areas.log.height, LOG_HEIGHT); assert_eq!(areas.footer.height, 1); assert_eq!( areas.body.height + areas.log.height + areas.footer.height, 24, "the three regions must tile the screen exactly, with no dead row" ); } // A short terminal drops the log rather than starving the body. Without this // the layout hands the body 1-2 rows and spends the rest on log borders. #[test] fn short_terminal_drops_the_log_pane() { let areas = console(Rect::new(0, 0, 80, 10)); assert_eq!( areas.log.height, 0, "log is dropped below the minimum height" ); assert_eq!(areas.body.height, 9); assert_eq!(areas.footer.height, 1); } // The degenerate case: a terminal so short there is only the footer. This // must not panic or produce a negative-height body. #[test] fn single_row_terminal_yields_footer_only() { let areas = console(Rect::new(0, 0, 80, 1)); assert_eq!(areas.body.height, 0); assert_eq!(areas.log.height, 0); assert_eq!(areas.footer.height, 1); } }