//! The arithmetic that turns a window into a terminal grid. //! //! Everything here is a pure function of numbers: how tall the workspace is //! once the chrome is subtracted, how many panes fit beside the rail, which //! slice of the attached panes is on screen, and how many rows and columns of //! a measured cell that leaves. None of it needs a renderer, and that is the //! reason it lives apart from one. //! //! It matters because this arithmetic is the contract with the PTY. A program //! running under Lumbridge lays itself out from the columns it is told it has, //! so an error of one column here is a wrapped line in `vim` and a broken table //! in `git log`. Decision 0009 fixes the pane capacities, and the numbers it //! quotes were once derived from a *guessed* cell width — the kind of mistake //! that is invisible in a screenshot and obvious in a test. Keeping the //! arithmetic gpui-free means those tests run in the fast headless CI job, //! against real numbers, without opening a window. //! //! The one thing deliberately *not* here is [`CellMetrics::measure`]. Asking //! the text system for a glyph advance needs a live `App`, so the constructor //! stays in the view layer and hands the answer down as plain `f32`s. The //! measurement is a renderer's job; what is done with it is not. use lumbridge_terminal::TerminalDimensions; const APP_HEADER_HEIGHT: f32 = 48.0; const TAB_BAR_HEIGHT: f32 = 38.0; pub(crate) const APP_FOOTER_HEIGHT: f32 = 46.0; const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0; const TERMINAL_HORIZONTAL_INSET: f32 = 32.0; const WORK_PANEL_GAP: f32 = 4.0; /// Fallbacks, used only until the text system has been asked. /// /// These were the whole story until now, and 8.4 was a guess: nothing had ever /// measured the monospace face actually in use, so the column arithmetic and the /// painted glyph advance were two independent opinions that happened to be /// close. [`CellMetrics`] replaces them with a measurement. pub(crate) const TERMINAL_CELL_WIDTH: f32 = 8.4; pub(crate) const TERMINAL_CELL_HEIGHT: f32 = 18.0; /// A window's size in logical pixels, with no renderer type attached. /// /// The view layer holds a `gpui::Size` and converts on the way in. That /// conversion is one line and it buys the whole module: the layout rules below /// can be exercised by a test that names two numbers, rather than by a test /// that needs a windowing system to exist. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct WindowSize { pub(crate) width: f32, pub(crate) height: f32, } /// The measured size of one terminal cell. /// /// A terminal's whole geometry is columns × advance. Guessing the advance means /// the last column is clipped or a gap is left, and it means the size handed to /// the PTY describes a window that is not the one on screen. #[derive(Clone, Copy, Debug)] pub(crate) struct CellMetrics { pub(crate) advance: f32, pub(crate) line_height: f32, } impl Default for CellMetrics { fn default() -> Self { Self { advance: TERMINAL_CELL_WIDTH, line_height: TERMINAL_CELL_HEIGHT, } } } /// Widens a small count for layout arithmetic. /// /// `f32` represents every integer below 2^24 exactly, and the values passed /// here are panel and column counts. Saturating there rather than at `usize::MAX` /// keeps the conversion exact for every value this can actually receive. pub(crate) fn lossless_f32(value: usize) -> f32 { const EXACT_LIMIT: usize = 1 << 24; #[allow( clippy::cast_precision_loss, reason = "clamped below 2^24, where f32 is exact" )] { value.min(EXACT_LIMIT) as f32 } } /// Narrows an already-clamped dimension. /// /// Every caller clamps into `2.0..=u16::MAX` first, so this is a narrowing of a /// value known to fit — but `as` would silently produce garbage if a caller ever /// stopped clamping, and a NaN would become zero. This saturates instead, which /// is why the `with_cell_size` expect below is honest rather than hopeful. pub(crate) fn clamp_to_u16(value: f32) -> u16 { if value.is_nan() { return 1; } #[allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "clamped into u16 range on the line above" )] { value.clamp(0.0, f32::from(u16::MAX)) as u16 } } pub(crate) fn terminal_dimensions_for_window( window_size: WindowSize, attached_panel_count: usize, sidebar_width: f32, cell: CellMetrics, ) -> TerminalDimensions { let width = window_size.width; let height = window_size.height; let panel_count = visible_panel_count(window_size, sidebar_width) .min(attached_panel_count) .max(1); let workspace_height = (height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0); let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0); let workspace_width = (width - sidebar_width).max(0.0); // A panel count is single digits; the precision lint is about 2^24 and up. let panel_gaps = WORK_PANEL_GAP * lossless_f32(panel_count.saturating_sub(1)); let panel_width = ((workspace_width - panel_gaps).max(0.0) / lossless_f32(panel_count)).max(0.0); let terminal_width = (panel_width - TERMINAL_HORIZONTAL_INSET).max(0.0); let rows = (terminal_height / cell.line_height) .floor() .clamp(2.0, f32::from(u16::MAX)); let columns = (terminal_width / cell.advance) .floor() .clamp(20.0, f32::from(u16::MAX)); TerminalDimensions::with_cell_size( clamp_to_u16(rows), clamp_to_u16(columns), clamp_to_u16(cell.advance.round()), clamp_to_u16(cell.line_height.round()), ) .expect("geometry clamps terminal dimensions above zero") } /// How many panes fit beside the rail. /// /// Measured *after* the sidebar, as decision 0009 specifies, which is why /// widening the rail can drop the workspace from three panes to one. That is /// correct and it is visible while dragging. pub(crate) fn visible_panel_count(window_size: WindowSize, sidebar_width: f32) -> usize { let workspace_width = (window_size.width - sidebar_width).max(0.0); if workspace_width >= 2_800.0 { 5 } else if workspace_width >= 1_100.0 { 3 } else { 1 } } pub(crate) fn visible_panel_range( total: usize, selected: usize, panel_count: usize, ) -> std::ops::Range { let count = panel_count.clamp(1, total.max(1)).min(total); let start = selected .saturating_sub(count / 2) .min(total.saturating_sub(count)); start..start + count } #[cfg(test)] mod tests { use super::{ CellMetrics, WindowSize, terminal_dimensions_for_window, visible_panel_count, visible_panel_range, }; use crate::sidebar::model::DEFAULT_WIDTH; #[test] fn terminal_geometry_tracks_middle_sixty_percent_per_panel() { let dimensions = terminal_dimensions_for_window( WindowSize { width: 1500.0, height: 960.0, }, 5, 248.0, CellMetrics::default(), ); assert_eq!(dimensions.rows(), 26); assert_eq!(dimensions.columns(), 45); let ultrawide = WindowSize { width: 3440.0, height: 1440.0, }; assert_eq!(visible_panel_count(ultrawide, DEFAULT_WIDTH), 5); let dimensions = terminal_dimensions_for_window(ultrawide, 5, DEFAULT_WIDTH, CellMetrics::default()); assert_eq!(dimensions.rows(), 42); assert_eq!(dimensions.columns(), 71); let two_panels = terminal_dimensions_for_window(ultrawide, 2, DEFAULT_WIDTH, CellMetrics::default()); assert_eq!(two_panels.rows(), 42); assert_eq!(two_panels.columns(), 185); } #[test] fn panel_window_keeps_the_selected_pane_visible() { assert_eq!(visible_panel_range(6, 0, 5), 0..5); assert_eq!(visible_panel_range(6, 5, 5), 1..6); assert_eq!(visible_panel_range(6, 3, 3), 2..5); assert_eq!(visible_panel_range(6, 4, 1), 4..5); } }