diff --git a/apps/lumbridge/src/geometry.rs b/apps/lumbridge/src/geometry.rs new file mode 100644 index 0000000..2072fce --- /dev/null +++ b/apps/lumbridge/src/geometry.rs @@ -0,0 +1,217 @@ +//! 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); + } +} diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 16a942a..6c1d4e5 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,4 +1,5 @@ mod attention; +mod geometry; mod keymap; mod panel_registry; mod settings_view; @@ -31,6 +32,10 @@ use lumbridge_ui_fixture::{ }; use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; +use geometry::{ + APP_FOOTER_HEIGHT, CellMetrics, TERMINAL_CELL_HEIGHT, TERMINAL_CELL_WIDTH, WindowSize, + clamp_to_u16, terminal_dimensions_for_window, visible_panel_count, visible_panel_range, +}; use lumbridge_harness::MonotonicWallClock; use lumbridge_settings::{Page, ProcessEnv, Settings, SettingsContent}; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; @@ -45,19 +50,6 @@ const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike"; -const APP_HEADER_HEIGHT: f32 = 48.0; -const TAB_BAR_HEIGHT: f32 = 38.0; -const APP_FOOTER_HEIGHT: f32 = 46.0; -const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0; -const TERMINAL_HORIZONTAL_INSET: f32 = 32.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. -const TERMINAL_CELL_WIDTH: f32 = 8.4; -const TERMINAL_CELL_HEIGHT: f32 = 18.0; /// The point size the terminal is painted at. const TERMINAL_FONT_SIZE: f32 = 13.0; /// Named rather than "monospace" so the measurement and the painting agree on a @@ -65,22 +57,18 @@ const TERMINAL_FONT_SIZE: f32 = 13.0; /// and the renderer pick another. const TERMINAL_FONT_FAMILY: &str = "monospace"; -/// The measured size of one terminal cell. +/// Converts the renderer's window size into the plain numbers the layout +/// arithmetic works in. /// -/// 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)] -struct CellMetrics { - advance: f32, - line_height: f32, -} - -impl Default for CellMetrics { - fn default() -> Self { +/// This one line is the entire boundary between the view layer and +/// [`crate::geometry`]. Doing the conversion here rather than letting +/// `Size` reach the arithmetic is what keeps the pane-capacity and +/// row/column rules testable without a windowing system. +impl From> for WindowSize { + fn from(size: Size) -> Self { Self { - advance: TERMINAL_CELL_WIDTH, - line_height: TERMINAL_CELL_HEIGHT, + width: f32::from(size.width), + height: f32::from(size.height), } } } @@ -118,7 +106,6 @@ impl CellMetrics { } } } -const WORK_PANEL_GAP: f32 = 4.0; /// How close to the seam a press has to land to start a resize. const SIDEBAR_GRAB_RADIUS: f32 = 4.0; const TERMINAL_ROW_STEP: u16 = 2; @@ -434,46 +421,6 @@ fn terminal_paint_rows( .collect() } -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -/// 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. -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. -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 - } -} - #[allow( clippy::unreadable_literal, reason = "six-digit colour hex reads whole" @@ -555,69 +502,6 @@ fn dim_color(color: Rgba) -> Rgba { } } -fn terminal_dimensions_for_window( - window_size: Size, - attached_panel_count: usize, - sidebar_width: f32, - cell: CellMetrics, -) -> TerminalDimensions { - let width = f32::from(window_size.width); - let height = f32::from(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. -fn visible_panel_count(window_size: Size, sidebar_width: f32) -> usize { - let workspace_width = (f32::from(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 - } -} - -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 -} - impl RenderTiming { fn mark_dispatch(&mut self) { self.pending_dispatch = Some(Instant::now()); @@ -764,7 +648,7 @@ impl LumbridgeShell { let sidebar = SidebarState::default(); let cell = CellMetrics::measure(cx); let terminal_dimensions = terminal_dimensions_for_window( - window.bounds().size, + window.bounds().size.into(), panels.attached_count(), sidebar.width(), cell, @@ -787,7 +671,7 @@ impl LumbridgeShell { cx.observe_window_bounds(window, |shell, window, cx| { let dimensions = terminal_dimensions_for_window( - window.bounds().size, + window.bounds().size.into(), shell.panels.attached_count(), shell.sidebar.width(), shell.cell, @@ -1127,7 +1011,7 @@ impl LumbridgeShell { fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context) { let dimensions = terminal_dimensions_for_window( - window.bounds().size, + window.bounds().size.into(), self.panels.attached_count(), self.sidebar.width(), self.cell, @@ -1150,7 +1034,7 @@ impl LumbridgeShell { let pane = self.panels.create(kind); if kind == PanelKind::Terminal { let dimensions = terminal_dimensions_for_window( - window.bounds().size, + window.bounds().size.into(), self.panels.attached_count(), self.sidebar.width(), self.cell, @@ -1200,7 +1084,7 @@ impl LumbridgeShell { return; } let dimensions = terminal_dimensions_for_window( - window.bounds().size, + window.bounds().size.into(), self.panels.attached_count(), self.sidebar.width(), self.cell, @@ -3502,7 +3386,7 @@ fn provenance_chip( impl Render for LumbridgeShell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let panel_capacity = visible_panel_count(window.bounds().size, self.sidebar.width()); + let panel_capacity = visible_panel_count(window.bounds().size.into(), self.sidebar.width()); let attached_panes = self.panels.attached_ids(); let attached_count = attached_panes.len(); let detached_entries = self @@ -3743,14 +3627,11 @@ fn main() { #[cfg(test)] mod tests { - use super::CellMetrics; use super::{ KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalScroll, - indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts, - terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range, + indexed_terminal_color, terminal_key_from_parts, terminal_paint_rows, + terminal_scroll_from_parts, }; - use crate::sidebar::model::DEFAULT_WIDTH; - use gpui::{px, size}; #[test] fn maps_named_and_composed_gpui_keys_to_terminal_input() { @@ -3802,38 +3683,6 @@ mod tests { assert_eq!(control.key, TerminalKey::Text("c".into())); } - #[test] - fn terminal_geometry_tracks_middle_sixty_percent_per_panel() { - let dimensions = terminal_dimensions_for_window( - size(px(1500.0), px(960.0)), - 5, - 248.0, - CellMetrics::default(), - ); - assert_eq!(dimensions.rows(), 26); - assert_eq!(dimensions.columns(), 45); - - let ultrawide = size(px(3440.0), px(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); - } - #[test] fn shift_navigation_controls_scrollback_without_reaching_the_pty() { assert_eq!(