//! Spacing, as relationships rather than column counts. //! //! `makeover` resolves colour, which varies by theme. `makeover-geometry` //! carries what does not, and this is the terminal's reading of it: a //! [`Gap`] names *what is being separated* and this module answers how many //! cells that is here. //! //! Why bother in a TUI, where the vocabulary collapses hard. A terminal cell is //! coarse enough that four of the six gaps round to nothing horizontally and //! five of six vertically, so most of what this module says is "no space". That //! is the point rather than a disappointment: it says it *consistently*, and a //! widget that asks for `Gap::Group` gets the same answer as every other widget //! that asks, instead of one of them being written with a hardcoded 1 and its //! neighbour with a 2. //! //! # Cells are not square //! //! A terminal cell is roughly 8x17 device pixels, so one row of space reads as //! about twice the gap one column does. Treating the grid as one surface means //! every gap is either visually thin horizontally or visually fat vertically. //! //! So there are two surfaces, one per axis, and they differ only in base. The //! horizontal one is `Surface::terminal()` — a one-cell base and a one-cell //! quantum, the crate's own definition. The vertical one keeps the one-cell //! quantum (a row is still the smallest thing a terminal can draw) and halves //! the base, which is what "a row is worth two columns" means expressed as a //! measurement rather than as a special case. //! //! What falls out is the behaviour a terminal UI wants and would otherwise be //! hand-tuned into every widget: containers get side padding and spend no rows //! on it, and vertical space opens up only at pane scale, where a blank row is //! carrying a real division rather than decorating one. //! //! | | bound | peer | group | section | pane | page | //! |---|---|---|---|---|---|---| //! | columns | 0 | 0 | 1 | 1 | 2 | 2 | //! | rows | 0 | 0 | 0 | 0 | 1 | 1 | //! //! This answers the open question the crate's design note left for its first //! terminal consumer. Nothing above is terminal-specific machinery: `Surface` //! already takes a base and a quantum, and a second surface for the second axis //! is a use of that, not an extension to it. use makeover_geometry::Surface; use ratatui::widgets::Padding; pub use makeover_geometry::{Density, Gap, Step}; /// The console is driven by a keyboard and read at desk distance. There is no /// touch terminal to be the other case, so this is the only density in play — /// named rather than assumed, so the call sites read the same as a webview's. pub const DENSITY: Density = Density::Pointer; /// Columns. One cell is the base and one cell is the quantum. fn horizontal() -> Surface { Surface::terminal() } /// Rows. Same quantum — a row is the smallest thing a terminal draws — against /// half the base, because a row of space is worth about two columns of it. fn vertical() -> Surface { Surface { base: 0.5, ..Surface::terminal() } } /// How many columns of separation `gap` is worth. #[must_use] pub fn columns(gap: Gap) -> u16 { clamp(horizontal().gap(gap, DENSITY)) } /// How many rows of separation `gap` is worth. #[must_use] pub fn rows(gap: Gap) -> u16 { clamp(vertical().gap(gap, DENSITY)) } /// `gap` as ratatui padding, per axis. /// /// What a bordered container wants inside its frame. [`Gap::Group`] is the /// usual answer, being the crate's name for a container's inner margin. #[must_use] pub fn padding(gap: Gap) -> Padding { Padding::symmetric(columns(gap), rows(gap)) } /// A terminal is addressed in `u16`, and no gap this vocabulary produces comes /// near that. Saturating rather than `as` so a future base could not silently /// wrap a layout. fn clamp(quanta: u32) -> u16 { u16::try_from(quanta).unwrap_or(u16::MAX) } #[cfg(test)] mod tests { use super::*; // The table in this module's own documentation. If these move, the doc is // wrong and so is every widget that trusted it. #[test] fn the_axes_resolve_as_documented() { let want = [ (Gap::Bound, 0, 0), (Gap::Peer, 0, 0), (Gap::Group, 1, 0), (Gap::Section, 1, 0), (Gap::Pane, 2, 1), (Gap::Page, 2, 1), ]; for (gap, cols, rws) in want { assert_eq!(columns(gap), cols, "{gap:?} columns"); assert_eq!(rows(gap), rws, "{gap:?} rows"); } } // The invariant the crate's design note calls out as the one that matters: // a looser relationship may collapse onto a tighter one, because a coarse // surface has fewer distinctions available, but it must never resolve // *tighter*. Checked per axis, since the two surfaces differ. #[test] fn a_looser_gap_never_resolves_tighter_than_a_closer_one() { for axis in [ ("columns", columns as fn(Gap) -> u16), ("rows", rows as fn(Gap) -> u16), ] { let (name, resolve) = axis; let ordered = Gap::all(); for pair in ordered.windows(2) { let (tight, loose) = (pair[0], pair[1]); assert!( resolve(loose) >= resolve(tight), "{name}: {loose:?} ({}) resolved tighter than {tight:?} ({})", resolve(loose), resolve(tight) ); } } } // A row costs about twice what a column does, so no gap should ever spend // more rows than columns. This is the whole reason there are two surfaces. #[test] fn no_gap_spends_more_rows_than_columns() { for gap in Gap::all() { assert!( rows(gap) <= columns(gap), "{gap:?} spends {} rows against {} columns", rows(gap), columns(gap) ); } } #[test] fn a_group_pads_the_sides_and_costs_no_rows() { assert_eq!(padding(Gap::Group), Padding::symmetric(1, 0)); } }