use serde::{Deserialize, Serialize}; use std::{ fmt::Display, hash::{Hash, Hasher}, }; use crate::data::FloatingPaneCoordinates; use crate::input::layout::{PercentOrFixed, SplitDirection, SplitSize}; use crate::position::Position; /// Contains the position and size of a [`Pane`], or more generally of any terminal, measured /// in character rows and columns. #[derive(Clone, Copy, Default, Debug, Serialize, Deserialize)] pub struct PaneGeom { pub x: usize, pub y: usize, pub rows: Dimension, pub cols: Dimension, pub stacked: Option, // usize - stack id pub is_pinned: bool, // only relevant to floating panes pub logical_position: Option, // relevant when placing this pane in a layout } impl PartialEq for PaneGeom { fn eq(&self, other: &Self) -> bool { // compare all except is_pinned // NOTE: Keep this in sync with what the `Hash` trait impl does. self.x == other.x && self.y == other.y && self.rows == other.rows && self.cols == other.cols && self.stacked == other.stacked } } impl std::hash::Hash for PaneGeom { fn hash(&self, state: &mut H) { // NOTE: Keep this in sync with what the `PartiqlEq` trait impl does. self.x.hash(state); self.y.hash(state); self.rows.hash(state); self.cols.hash(state); self.stacked.hash(state); } } impl Eq for PaneGeom {} #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct Viewport { pub x: usize, pub y: usize, pub rows: usize, pub cols: usize, } impl Viewport { /// Shrink this viewport by `by` cells on every side, keeping it centred in /// the space it started from. Used to draw a pane's frame inset from the /// geometry the pane was actually allocated, which is what produces a /// visible gap between neighbouring panes. /// /// Refuses to inset a viewport that is too small to survive it: a frame /// needs at least its two borders plus a cell of content in each axis, so /// below that we return the viewport untouched rather than emit a /// degenerate (or zero-sized) frame. /// Shrink this viewport by a different amount on each side. /// /// Used for the pane gap: a pane only gives up space on a side where a gap /// actually belongs, which is what makes the gap between two neighbours /// equal to `gap` rather than `2 * gap`. /// /// Refuses (returns self) if the result would not leave a viable frame. pub fn inset_by(&self, by: Offset) -> Viewport { const MIN_FRAMED_EXTENT: usize = 3; if by.top == 0 && by.bottom == 0 && by.left == 0 && by.right == 0 { return *self; } let horizontal = by.left.saturating_add(by.right); let vertical = by.top.saturating_add(by.bottom); if self.cols < horizontal.saturating_add(MIN_FRAMED_EXTENT) || self.rows < vertical.saturating_add(MIN_FRAMED_EXTENT) { return *self; } Viewport { x: self.x.saturating_add(by.left), y: self.y.saturating_add(by.top), cols: self.cols.saturating_sub(horizontal), rows: self.rows.saturating_sub(vertical), } } pub fn inset(&self, by: usize) -> Viewport { const MIN_FRAMED_EXTENT: usize = 3; // left border + 1 content cell + right border if by == 0 { return *self; } let shrink = by.saturating_mul(2); if self.cols < shrink.saturating_add(MIN_FRAMED_EXTENT) || self.rows < shrink.saturating_add(MIN_FRAMED_EXTENT) { return *self; } Viewport { x: self.x.saturating_add(by), y: self.y.saturating_add(by), cols: self.cols.saturating_sub(shrink), rows: self.rows.saturating_sub(shrink), } } pub fn has_positive_size(&self) -> bool { self.rows > 0 && self.cols > 0 } } #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct Offset { pub top: usize, pub bottom: usize, pub right: usize, pub left: usize, } #[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Size { pub rows: usize, pub cols: usize, } #[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct SizeInPixels { pub height: usize, pub width: usize, } #[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)] pub struct Insets { pub top: usize, pub bottom: usize, pub left: usize, pub right: usize, } #[derive(Eq, Clone, Copy, PartialEq, Debug, Serialize, Deserialize, Hash)] pub struct Dimension { pub constraint: Constraint, pub(crate) inner: usize, } impl Default for Dimension { fn default() -> Self { Self::percent(100.0) } } impl Dimension { pub fn fixed(size: usize) -> Dimension { Self { constraint: Constraint::Fixed(size), inner: size, } } pub fn percent(percent: f64) -> Dimension { Self { constraint: Constraint::Percent(percent), inner: 1, } } pub fn as_usize(&self) -> usize { self.inner } pub fn as_percent(&self) -> Option { if let Constraint::Percent(p) = self.constraint { Some(p) } else { None } } pub fn set_percent(&mut self, percent: f64) { self.constraint = Constraint::Percent(percent); } pub fn set_inner(&mut self, inner: usize) { self.inner = inner; } pub fn adjust_inner(&mut self, full_size: usize) -> f64 { // returns the leftover from // rounding if any // TODO: elsewhere? match self.constraint { Constraint::Percent(percent) => { let new_inner = (percent / 100.0) * full_size as f64; let rounded = new_inner.floor(); let leftover = rounded - new_inner; self.set_inner(rounded as usize); leftover }, Constraint::Fixed(fixed_size) => { self.set_inner(fixed_size); 0.0 }, } } pub fn increase_inner(&mut self, by: usize) { self.inner += by; } pub fn decrease_inner(&mut self, by: usize) { self.inner = self.inner.saturating_sub(by); } pub fn is_fixed(&self) -> bool { matches!(self.constraint, Constraint::Fixed(_)) } pub fn is_percent(&self) -> bool { matches!(self.constraint, Constraint::Percent(_)) } pub fn from_split_size(split_size: SplitSize, full_size: usize) -> Self { match split_size { SplitSize::Fixed(fixed) => Dimension { constraint: Constraint::Fixed(fixed), inner: fixed, }, SplitSize::Percent(percent) => Dimension { constraint: Constraint::Percent(percent as f64), inner: ((percent as f64 / 100.0) * full_size as f64).floor() as usize, }, } } pub fn from_percent_or_fixed(size: PercentOrFixed, full_size: usize) -> Self { match size { PercentOrFixed::Fixed(fixed) => Dimension { constraint: Constraint::Fixed(fixed), inner: fixed, }, PercentOrFixed::Percent(percent) => Dimension { constraint: Constraint::Percent(percent as f64), inner: ((percent as f64 / 100.0) * full_size as f64).floor() as usize, }, } } pub fn split_out(&mut self, by: f64) -> Self { match self.constraint { Constraint::Percent(percent) => { let split_out_value = percent / by; let split_out_inner_value = self.inner / by as usize; self.constraint = Constraint::Percent(percent - split_out_value); self.inner = self.inner.saturating_sub(split_out_inner_value); let mut split_out_dimension = Self::percent(split_out_value); split_out_dimension.inner = split_out_inner_value; split_out_dimension }, Constraint::Fixed(fixed) => { let split_out_value = fixed / by as usize; self.constraint = Constraint::Fixed(fixed - split_out_value); Self::fixed(split_out_value) }, } } pub fn reduce_by(&mut self, by: f64, by_inner: usize) { match self.constraint { Constraint::Percent(percent) => { self.constraint = Constraint::Percent(percent - by); self.inner = self.inner.saturating_sub(by_inner); }, Constraint::Fixed(_fixed) => { log::error!("Cannot reduce_by fixed dimensions"); }, } } } #[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] pub enum Constraint { /// Constrains the dimension to a fixed, integer number of rows / columns Fixed(usize), /// Constrains the dimension to a flexible percent size of the total screen Percent(f64), } impl Display for Constraint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let actual = match self { Constraint::Fixed(v) => *v as f64, Constraint::Percent(v) => *v, }; write!(f, "{}", actual)?; Ok(()) } } #[allow(clippy::derive_hash_xor_eq)] impl Hash for Constraint { fn hash(&self, state: &mut H) { match self { Constraint::Fixed(size) => size.hash(state), Constraint::Percent(size) => (*size as usize).hash(state), } } } impl Eq for Constraint {} impl PaneGeom { pub fn contains(&self, point: &Position) -> bool { let col = point.column.0 as usize; let row = point.line.0 as usize; self.x <= col && col < self.x + self.cols.as_usize() && self.y <= row && row < self.y + self.rows.as_usize() } pub fn is_at_least_minimum_size(&self) -> bool { self.rows.as_usize() > 0 && self.cols.as_usize() > 0 } pub fn is_flexible_in_direction(&self, split_direction: SplitDirection) -> bool { match split_direction { SplitDirection::Vertical => self.cols.is_percent(), SplitDirection::Horizontal => self.rows.is_percent(), } } pub fn apply_floating_pane_position( &mut self, x: Option, y: Option, width: Option, height: Option, viewport_cols: usize, viewport_rows: usize, ) { if let Some(width) = &width { self.cols = Dimension::fixed(width.to_fixed(viewport_cols)); } if let Some(height) = &height { self.rows = Dimension::fixed(height.to_fixed(viewport_rows)); } self.x = match &x { Some(x) => x.to_fixed(viewport_cols), None if width.is_some() => viewport_cols.saturating_sub(self.cols.as_usize()) / 2, None => self.x, }; self.y = match &y { Some(y) => y.to_fixed(viewport_rows), None if height.is_some() => viewport_rows.saturating_sub(self.rows.as_usize()) / 2, None => self.y, }; } pub fn adjust_coordinates( &mut self, floating_pane_coordinates: FloatingPaneCoordinates, viewport: Viewport, ) { self.apply_floating_pane_position( floating_pane_coordinates.x, floating_pane_coordinates.y, floating_pane_coordinates.width, floating_pane_coordinates.height, viewport.cols, viewport.rows, ); if self.x < viewport.x { self.x = viewport.x; } else if self.x > viewport.x + viewport.cols { self.x = (viewport.x + viewport.cols).saturating_sub(self.cols.as_usize()); } if self.y < viewport.y { self.y = viewport.y; } else if self.y > viewport.y + viewport.rows { self.y = (viewport.y + viewport.rows).saturating_sub(self.rows.as_usize()); } if self.x + self.cols.as_usize() > viewport.x + viewport.cols { let new_cols = (viewport.x + viewport.cols).saturating_sub(self.x); self.cols.set_inner(new_cols); } if self.y + self.rows.as_usize() > viewport.y + viewport.rows { let new_rows = (viewport.y + viewport.rows).saturating_sub(self.y); self.rows.set_inner(new_rows); } } pub fn combine_vertically_with(&self, geom_below: &PaneGeom) -> Option { match (self.rows.constraint, geom_below.rows.constraint) { (Constraint::Percent(self_percent), Constraint::Percent(geom_below_percent)) => { let mut combined = self.clone(); combined.rows = Dimension::percent(self_percent + geom_below_percent); combined.rows.inner = self.rows.inner + geom_below.rows.inner; Some(combined) }, _ => { log::error!("Can't combine fixed panes"); None }, } } pub fn combine_horizontally_with(&self, geom_to_the_right: &PaneGeom) -> Option { match (self.cols.constraint, geom_to_the_right.cols.constraint) { (Constraint::Percent(self_percent), Constraint::Percent(geom_to_the_right_percent)) => { let mut combined = self.clone(); combined.cols = Dimension::percent(self_percent + geom_to_the_right_percent); combined.cols.inner = self.cols.inner + geom_to_the_right.cols.inner; Some(combined) }, _ => { log::error!("Can't combine fixed panes"); None }, } } pub fn combine_vertically_with_many(&self, geoms_below: &Vec) -> Option { // here we expect the geoms to be sorted by their y and be contiguous (i.e. same x and // width, no overlaps) and be below self let mut combined = self.clone(); for geom_below in geoms_below { match (combined.rows.constraint, geom_below.rows.constraint) { ( Constraint::Percent(combined_percent), Constraint::Percent(geom_below_percent), ) => { let new_rows_inner = combined.rows.inner + geom_below.rows.inner; combined.rows = Dimension::percent(combined_percent + geom_below_percent); combined.rows.inner = new_rows_inner; }, _ => { log::error!("Can't combine fixed panes"); return None; }, } } Some(combined) } pub fn combine_horizontally_with_many( &self, geoms_to_the_right: &Vec, ) -> Option { // here we expect the geoms to be sorted by their x and be contiguous (i.e. same x and // width, no overlaps) and be right of self let mut combined = self.clone(); for geom_to_the_right in geoms_to_the_right { match (combined.cols.constraint, geom_to_the_right.cols.constraint) { ( Constraint::Percent(combined_percent), Constraint::Percent(geom_to_the_right_percent), ) => { let new_cols = combined.cols.inner + geom_to_the_right.cols.inner; combined.cols = Dimension::percent(combined_percent + geom_to_the_right_percent); combined.cols.inner = new_cols; }, _ => { log::error!("Can't combine fixed panes"); return None; }, } } Some(combined) } pub fn is_stacked(&self) -> bool { self.stacked.is_some() } } impl Display for PaneGeom { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{{ ")?; write!(f, r#""x": {},"#, self.x)?; write!(f, r#""y": {},"#, self.y)?; write!(f, r#""cols": {},"#, self.cols.constraint)?; write!(f, r#""rows": {},"#, self.rows.constraint)?; write!(f, r#""stacked": {:?}"#, self.stacked)?; write!(f, r#""logical_position": {:?}"#, self.logical_position)?; write!(f, " }}")?; Ok(()) } } impl Offset { /// Grow this offset by `by` cells on every side. /// /// Used to push a pane's content inward for the outer gap (so the content /// stays inside the inset frame) and for the inner padding (breathing room /// between the frame and the content). Content position and size, and the /// cursor, are all derived from content_offset, so widening it here is /// enough to keep every consumer consistent. pub fn expanded_by(&self, by: usize) -> Self { if by == 0 { return *self; } Self { top: self.top.saturating_add(by), bottom: self.bottom.saturating_add(by), left: self.left.saturating_add(by), right: self.right.saturating_add(by), } } /// As [`Offset::expanded_by`], but refuses to expand when doing so would /// leave a pane of `cols` x `rows` with no room for content. /// /// This exists because of a real regression: a one-row `borderless` status /// bar pane expanded by a gap of 1 ends up with top = bottom = 1 and thus /// zero content rows, i.e. it silently disappears. Chrome panes are now /// excluded by their callers, but any pane can be resized down to nothing, /// so the geometry refuses to collapse itself regardless of caller. /// As [`Offset::expanded_by_within`], but grows each side independently. pub fn expanded_by_offset_within(&self, by: Offset, cols: usize, rows: usize) -> Self { let expanded = Self { top: self.top.saturating_add(by.top), bottom: self.bottom.saturating_add(by.bottom), left: self.left.saturating_add(by.left), right: self.right.saturating_add(by.right), }; let fits = cols .checked_sub(expanded.left + expanded.right) .is_some_and(|c| c >= 1) && rows .checked_sub(expanded.top + expanded.bottom) .is_some_and(|r| r >= 1); if fits { expanded } else { *self } } pub fn expanded_by_within(&self, by: usize, cols: usize, rows: usize) -> Self { let expanded = self.expanded_by(by); let fits = cols .checked_sub(expanded.left + expanded.right) .is_some_and(|c| c >= 1) && rows .checked_sub(expanded.top + expanded.bottom) .is_some_and(|r| r >= 1); if fits { expanded } else { *self } } pub fn frame(size: usize) -> Self { Self { top: size, bottom: size, right: size, left: size, } } pub fn shift_right_and_top(right: usize, top: usize) -> Self { Self { right, top, ..Default::default() } } pub fn shift_right(right: usize) -> Self { Self { right, ..Default::default() } } pub fn shift_right_top_and_bottom(right: usize, top: usize, bottom: usize) -> Self { Self { right, top, bottom, ..Default::default() } } // FIXME: This should be top and left, not bottom and right, but `boundaries.rs` would need // some changing pub fn shift(bottom: usize, right: usize) -> Self { Self { bottom, right, ..Default::default() } } } impl From for Viewport { fn from(pane: PaneGeom) -> Self { Self { x: pane.x, y: pane.y, rows: pane.rows.as_usize(), cols: pane.cols.as_usize(), } } } impl From for Viewport { fn from(size: Size) -> Self { Self { rows: size.rows, cols: size.cols, ..Default::default() } } } impl From<&PaneGeom> for Size { fn from(pane_geom: &PaneGeom) -> Self { Self { rows: pane_geom.rows.as_usize(), cols: pane_geom.cols.as_usize(), } } } impl From<&Size> for PaneGeom { fn from(size: &Size) -> Self { let mut rows = Dimension::percent(100.0); let mut cols = Dimension::percent(100.0); rows.set_inner(size.rows); cols.set_inner(size.cols); Self { rows, cols, ..Default::default() } } } #[cfg(test)] mod gap_and_padding_tests { use super::*; fn vp(x: usize, y: usize, cols: usize, rows: usize) -> Viewport { Viewport { x, y, cols, rows } } #[test] fn inset_zero_is_identity() { let v = vp(0, 0, 80, 24); assert_eq!(v.inset(0), v); } #[test] fn inset_shrinks_and_recentres() { let v = vp(10, 5, 80, 24).inset(1); assert_eq!((v.x, v.y, v.cols, v.rows), (11, 6, 78, 22)); let v = vp(10, 5, 80, 24).inset(2); assert_eq!((v.x, v.y, v.cols, v.rows), (12, 7, 76, 20)); } #[test] fn inset_keeps_the_pane_inside_its_allocation() { // the inset frame must never escape the geometry it was given, or it // would overdraw a neighbouring pane let (x, y, cols, rows) = (10, 5, 80, 24); for by in 0..12 { let v = vp(x, y, cols, rows).inset(by); assert!(v.x >= x, "by={} moved x left", by); assert!(v.y >= y, "by={} moved y up", by); assert!(v.x + v.cols <= x + cols, "by={} overflowed right edge", by); assert!(v.y + v.rows <= y + rows, "by={} overflowed bottom edge", by); } } #[test] fn inset_refuses_to_collapse_small_panes() { // too small to hold two borders plus a content cell: left untouched // rather than emitting a zero-sized or inverted frame for (cols, rows) in [(4, 24), (24, 4), (1, 1), (4, 4)] { let v = vp(0, 0, cols, rows); assert_eq!(v.inset(1), v, "{}x{} should be left alone", cols, rows); } // 5x5 is exactly the threshold for by=1 (2*1 + 3): it survives, and // insets down to a still-legal 3x3 frame (two borders + one cell) assert_eq!(vp(0, 0, 5, 5).inset(1), vp(1, 1, 3, 3)); assert_eq!(vp(0, 0, 6, 6).inset(1), vp(1, 1, 4, 4)); // a bigger gap needs a correspondingly bigger pane to survive assert_eq!(vp(0, 0, 6, 6).inset(2), vp(0, 0, 6, 6)); assert_eq!(vp(0, 0, 7, 7).inset(2), vp(2, 2, 3, 3)); } /// Model of the per-side rule in tiled_panes: right/bottom always, left/top /// only at the viewport edge. fn gap_for(geom: Viewport, vp: Viewport, gx: usize, gy: usize) -> Offset { Offset { left: if geom.x <= vp.x { gx } else { 0 }, top: if geom.y <= vp.y { gy } else { 0 }, right: gx, bottom: gy, } } #[test] fn stacked_panes_get_exactly_one_gap_between_them() { // THE regression this rule fixes: insetting every side on every pane // made two neighbours produce 2*gap between them. let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 }; let (gx, gy) = (2usize, 1usize); let top_alloc = Viewport { x: 0, y: 0, cols: 80, rows: 12 }; let bottom_alloc = Viewport { x: 0, y: 12, cols: 80, rows: 12 }; let top = top_alloc.inset_by(gap_for(top_alloc, vp, gx, gy)); let bottom = bottom_alloc.inset_by(gap_for(bottom_alloc, vp, gx, gy)); let between = bottom.y - (top.y + top.rows); assert_eq!(between, gy, "vertical gap between neighbours must be exactly gy"); } #[test] fn side_by_side_panes_get_exactly_one_gap_between_them() { let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 }; let (gx, gy) = (2usize, 1usize); let left_alloc = Viewport { x: 0, y: 0, cols: 40, rows: 24 }; let right_alloc = Viewport { x: 40, y: 0, cols: 40, rows: 24 }; let left = left_alloc.inset_by(gap_for(left_alloc, vp, gx, gy)); let right = right_alloc.inset_by(gap_for(right_alloc, vp, gx, gy)); let between = right.x - (left.x + left.cols); assert_eq!(between, gx, "horizontal gap between neighbours must be exactly gx"); } #[test] fn screen_edges_get_the_same_gap_as_between_panes() { let vp = Viewport { x: 0, y: 0, cols: 80, rows: 24 }; let (gx, gy) = (2usize, 1usize); let a = Viewport { x: 0, y: 0, cols: 40, rows: 24 }; let b = Viewport { x: 40, y: 0, cols: 40, rows: 24 }; let left = a.inset_by(gap_for(a, vp, gx, gy)); let right = b.inset_by(gap_for(b, vp, gx, gy)); assert_eq!(left.x - vp.x, gx, "left screen margin"); assert_eq!((vp.x + vp.cols) - (right.x + right.cols), gx, "right screen margin"); assert_eq!(left.y - vp.y, gy, "top screen margin"); assert_eq!((vp.y + vp.rows) - (left.y + left.rows), gy, "bottom screen margin"); } #[test] fn inset_by_zero_is_identity_and_refuses_to_collapse() { let v = Viewport { x: 2, y: 3, cols: 20, rows: 10 }; assert_eq!(v.inset_by(Offset::default()), v); let tiny = Viewport { x: 0, y: 0, cols: 4, rows: 4 }; assert_eq!(tiny.inset_by(Offset::frame(1)), tiny, "must not collapse a tiny pane"); } #[test] fn expanded_by_grows_every_side() { let o = Offset::frame(1).expanded_by(2); assert_eq!((o.top, o.bottom, o.left, o.right), (3, 3, 3, 3)); let o = Offset::default().expanded_by(0); assert_eq!((o.top, o.bottom, o.left, o.right), (0, 0, 0, 0)); } #[test] fn expanded_by_within_never_collapses_a_one_row_status_bar() { // REGRESSION: `gap 1` made the zjstatus footer disappear. A size=1 // borderless pane expanded by 1 gets top=bottom=1 -> 0 content rows. let bar = Offset::default(); let got = bar.expanded_by_within(1, 80, 1); assert_eq!( (got.top, got.bottom), (0, 0), "a 1-row pane must not be inset vertically" ); let rows_left = 1usize.saturating_sub(got.top + got.bottom); assert!(rows_left >= 1, "status bar must keep at least one content row"); } #[test] fn expanded_by_within_applies_when_there_is_room() { let got = Offset::frame(1).expanded_by_within(1, 80, 24); assert_eq!((got.top, got.bottom, got.left, got.right), (2, 2, 2, 2)); } #[test] fn expanded_by_within_leaves_content_for_any_pane_size() { // no pane size may ever be reduced to zero content in either axis for rows in 1..12usize { for cols in 1..12usize { for by in 0..4usize { let got = Offset::frame(1).expanded_by_within(by, cols, rows); let c = cols.saturating_sub(got.left + got.right); let r = rows.saturating_sub(got.top + got.bottom); // Offset::frame(1) itself can exceed a tiny pane; what we // assert is that expansion never makes things worse than // the offset we were handed. let base = Offset::frame(1); let base_c = cols.saturating_sub(base.left + base.right); let base_r = rows.saturating_sub(base.top + base.bottom); assert!( c >= base_c.min(1) || base_c == 0, "cols {} rows {} by {}: content cols {} < base {}", cols, rows, by, c, base_c ); assert!( r >= base_r.min(1) || base_r == 0, "cols {} rows {} by {}: content rows {} < base {}", cols, rows, by, r, base_r ); } } } } #[test] fn frame_and_content_insets_agree() { // The invariant that makes gaps safe: content inset by (gap + border + // padding) must land strictly inside the frame drawn at inset(gap). let (gap, padding) = (1usize, 1usize); let alloc = vp(0, 0, 40, 20); let frame = alloc.inset(gap); let content = Offset::frame(1).expanded_by(gap + padding); let content_x = alloc.x + content.left; let content_y = alloc.y + content.top; let content_cols = alloc.cols.saturating_sub(content.left + content.right); let content_rows = alloc.rows.saturating_sub(content.top + content.bottom); assert!(content_x > frame.x, "content must start inside the frame border"); assert!(content_y > frame.y, "content must start inside the frame border"); assert!( content_x + content_cols < frame.x + frame.cols, "content must end inside the frame border" ); assert!( content_y + content_rows < frame.y + frame.rows, "content must end inside the frame border" ); } }