From 96d097ef76365f9fcbe2c4e677259914c0655054 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Sun, 30 Nov 2025 11:57:08 -0800 Subject: [PATCH] feat: implement Rect ops for moving (#1596) feat: implement Rect ops for moving Implemented `Add`, `AddAssign`, `Sub`, and `SubAssign` on `Rect` for `Offset`. This makes it possible to move rects ```rust let rect = Rect::new(1, 2, 3, 4); let moved = rect + Offset(1, 2); let moved = rect - Offset(1, 2); let moved = rect + Offset(-1, -2); ``` Additionally Rect, Size, Offset, and Position now all have MIN and MAX consts. --- examples/apps/input-form/src/main.rs | 6 +- ratatui-core/src/layout.rs | 4 +- ratatui-core/src/layout/offset.rs | 66 +++++++++++++ ratatui-core/src/layout/position.rs | 8 +- ratatui-core/src/layout/rect.rs | 97 ++++++++++--------- ratatui-core/src/layout/rect/ops.rs | 136 +++++++++++++++++++++++++++ ratatui-core/src/layout/size.rs | 6 ++ ratatui-widgets/examples/tabs.rs | 2 +- 8 files changed, 276 insertions(+), 49 deletions(-) create mode 100644 ratatui-core/src/layout/offset.rs create mode 100644 ratatui-core/src/layout/rect/ops.rs diff --git a/examples/apps/input-form/src/main.rs b/examples/apps/input-form/src/main.rs index ae816b0d..7fdd3c38 100644 --- a/examples/apps/input-form/src/main.rs +++ b/examples/apps/input-form/src/main.rs @@ -123,9 +123,9 @@ impl InputForm { frame.render_widget(&self.age, age_area); let cursor_position = match self.focus { - Focus::FirstName => first_name_area.offset(self.first_name.cursor_offset()), - Focus::LastName => last_name_area.offset(self.last_name.cursor_offset()), - Focus::Age => age_area.offset(self.age.cursor_offset()), + Focus::FirstName => first_name_area + self.first_name.cursor_offset(), + Focus::LastName => last_name_area + self.last_name.cursor_offset(), + Focus::Age => age_area + self.age.cursor_offset(), }; frame.set_cursor_position(cursor_position); } diff --git a/ratatui-core/src/layout.rs b/ratatui-core/src/layout.rs index 4b9a7ece..7b11196b 100644 --- a/ratatui-core/src/layout.rs +++ b/ratatui-core/src/layout.rs @@ -316,6 +316,7 @@ mod direction; mod flex; mod layout; mod margin; +mod offset; mod position; mod rect; mod size; @@ -326,6 +327,7 @@ pub use direction::Direction; pub use flex::Flex; pub use layout::{Layout, Spacing}; pub use margin::Margin; +pub use offset::Offset; pub use position::Position; -pub use rect::{Columns, Offset, Positions, Rect, Rows}; +pub use rect::{Columns, Positions, Rect, Rows}; pub use size::Size; diff --git a/ratatui-core/src/layout/offset.rs b/ratatui-core/src/layout/offset.rs new file mode 100644 index 00000000..324dcfa7 --- /dev/null +++ b/ratatui-core/src/layout/offset.rs @@ -0,0 +1,66 @@ +use crate::layout::Position; + +/// Amounts by which to move a [`Rect`](crate::layout::Rect). +/// +/// Positive numbers move to the right/bottom and negative to the left/top. +/// +/// See [`Rect::offset`](crate::layout::Rect::offset) for usage. +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Offset { + /// How much to move on the X axis + pub x: i32, + + /// How much to move on the Y axis + pub y: i32, +} + +impl Offset { + /// A zero offset + pub const ZERO: Self = Self::new(0, 0); + + /// The minimum offset + pub const MIN: Self = Self::new(i32::MIN, i32::MIN); + + /// The maximum offset + pub const MAX: Self = Self::new(i32::MAX, i32::MAX); + + /// Creates a new `Offset` with the given values. + pub const fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +impl From for Offset { + fn from(position: Position) -> Self { + Self { + x: i32::from(position.x), + y: i32::from(position.y), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_sets_components() { + assert_eq!(Offset::new(-3, 7), Offset { x: -3, y: 7 }); + } + + #[test] + fn constants_match_expected_values() { + assert_eq!(Offset::ZERO, Offset::new(0, 0)); + assert_eq!(Offset::MIN, Offset::new(i32::MIN, i32::MIN)); + assert_eq!(Offset::MAX, Offset::new(i32::MAX, i32::MAX)); + } + + #[test] + fn from_position_converts_coordinates() { + let position = Position::new(4, 9); + let offset = Offset::from(position); + + assert_eq!(offset, Offset::new(4, 9)); + } +} diff --git a/ratatui-core/src/layout/position.rs b/ratatui-core/src/layout/position.rs index 339cad0a..63893389 100644 --- a/ratatui-core/src/layout/position.rs +++ b/ratatui-core/src/layout/position.rs @@ -57,7 +57,13 @@ pub struct Position { impl Position { /// Position at the origin, the top left edge at 0,0 - pub const ORIGIN: Self = Self { x: 0, y: 0 }; + pub const ORIGIN: Self = Self::new(0, 0); + + /// Position at the minimum x and y values + pub const MIN: Self = Self::ORIGIN; + + /// Position at the maximum x and y values + pub const MAX: Self = Self::new(u16::MAX, u16::MAX); /// Create a new position pub const fn new(x: u16, y: u16) -> Self { diff --git a/ratatui-core/src/layout/rect.rs b/ratatui-core/src/layout/rect.rs index ef459956..72c2a065 100644 --- a/ratatui-core/src/layout/rect.rs +++ b/ratatui-core/src/layout/rect.rs @@ -3,10 +3,11 @@ use core::array::TryFromSliceError; use core::cmp::{max, min}; use core::fmt; -use crate::layout::{Margin, Position, Size}; +pub use self::iter::{Columns, Positions, Rows}; +use crate::layout::{Margin, Offset, Position, Size}; mod iter; -pub use iter::*; +mod ops; use super::{Constraint, Flex, Layout}; @@ -66,21 +67,57 @@ use super::{Constraint, Flex, Layout}; /// /// # Examples /// +/// To create a new `Rect`, use [`Rect::new`]. The size of the `Rect` will be clamped to keep the +/// right and bottom coordinates within `u16`. Note that this clamping does not occur when creating +/// a `Rect` directly. +/// +/// ```rust +/// use ratatui_core::layout::Rect; +/// +/// let rect = Rect::new(1, 2, 3, 4); +/// assert_eq!( +/// rect, +/// Rect { +/// x: 1, +/// y: 2, +/// width: 3, +/// height: 4 +/// } +/// ); +/// ``` +/// +/// You can also create a `Rect` from a [`Position`] and a [`Size`]. +/// /// ```rust /// use ratatui_core::layout::{Position, Rect, Size}; /// -/// // Create a rectangle manually -/// let rect = Rect::new(10, 5, 80, 20); -/// assert_eq!(rect.x, 10); -/// assert_eq!(rect.y, 5); -/// assert_eq!(rect.width, 80); -/// assert_eq!(rect.height, 20); +/// let position = Position::new(1, 2); +/// let size = Size::new(3, 4); +/// let rect = Rect::from((position, size)); +/// assert_eq!( +/// rect, +/// Rect { +/// x: 1, +/// y: 2, +/// width: 3, +/// height: 4 +/// } +/// ); +/// ``` /// -/// // Create from position and size -/// let rect = Rect::from((Position::new(10, 5), Size::new(80, 20))); +/// To move a `Rect` without modifying its size, add or subtract an [`Offset`] to it. +/// +/// ```rust +/// use ratatui_core::layout::{Offset, Rect}; +/// +/// let rect = Rect::new(1, 2, 3, 4); +/// let offset = Offset::new(5, 6); +/// let moved_rect = rect + offset; +/// assert_eq!(moved_rect, Rect::new(6, 8, 3, 4)); /// ``` /// /// For comprehensive layout documentation and examples, see the [`layout`](crate::layout) module. + #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Rect { @@ -94,30 +131,6 @@ pub struct Rect { pub height: u16, } -/// Amounts by which to move a [`Rect`](crate::layout::Rect). -/// -/// Positive numbers move to the right/bottom and negative to the left/top. -/// -/// See [`Rect::offset`] -#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Offset { - /// How much to move on the X axis - pub x: i32, - /// How much to move on the Y axis - pub y: i32, -} - -impl Offset { - /// A zero offset - pub const ZERO: Self = Self { x: 0, y: 0 }; - - /// Creates a new `Offset` with the given values. - pub const fn new(x: i32, y: i32) -> Self { - Self { x, y } - } -} - impl fmt::Display for Rect { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}x{}+{}+{}", self.width, self.height, self.x, self.y) @@ -133,6 +146,12 @@ impl Rect { height: 0, }; + /// The minimum possible Rect + pub const MIN: Self = Self::ZERO; + + /// The maximum possible Rect + pub const MAX: Self = Self::new(0, 0, u16::MAX, u16::MAX); + /// Creates a new `Rect`, with width and height limited to keep both bounds within `u16`. /// /// If the width or height would cause the right or bottom coordinate to be larger than the @@ -253,15 +272,7 @@ impl Rect { /// See [`Offset`] for details. #[must_use = "method returns the modified value"] pub fn offset(self, offset: Offset) -> Self { - Self { - x: i32::from(self.x) - .saturating_add(offset.x) - .clamp(0, i32::from(u16::MAX - self.width)) as u16, - y: i32::from(self.y) - .saturating_add(offset.y) - .clamp(0, i32::from(u16::MAX - self.height)) as u16, - ..self - } + self + offset } /// Returns a new `Rect` that contains both the current one and the given one. diff --git a/ratatui-core/src/layout/rect/ops.rs b/ratatui-core/src/layout/rect/ops.rs new file mode 100644 index 00000000..d2bd565a --- /dev/null +++ b/ratatui-core/src/layout/rect/ops.rs @@ -0,0 +1,136 @@ +use core::ops::{Add, AddAssign, Neg, Sub, SubAssign}; + +use super::{Offset, Rect}; + +impl Neg for Offset { + type Output = Self; + + /// Negates the offset. + /// + /// # Panics + /// + /// Panics if the negated value overflows (i.e. `x` or `y` is `i32::MIN`). + fn neg(self) -> Self { + Self { + x: self.x.neg(), + y: self.y.neg(), + } + } +} + +impl Add for Rect { + type Output = Self; + + /// Moves the rect by an offset without changing its size. + /// + /// If the offset would move the any of the rect's edges outside the bounds of `u16`, the + /// rect's position is clamped to the nearest edge. + fn add(self, offset: Offset) -> Self { + let max_x = i32::from(u16::MAX - self.width); + let max_y = i32::from(u16::MAX - self.height); + let x = i32::from(self.x).saturating_add(offset.x).clamp(0, max_x) as u16; + let y = i32::from(self.y).saturating_add(offset.y).clamp(0, max_y) as u16; + Self { x, y, ..self } + } +} + +impl Add for Offset { + type Output = Rect; + + /// Moves the rect by an offset without changing its size. + /// + /// If the offset would move the any of the rect's edges outside the bounds of `u16`, the + /// rect's position is clamped to the nearest edge. + fn add(self, rect: Rect) -> Rect { + rect + self + } +} + +impl Sub for Rect { + type Output = Self; + + /// Subtracts an offset from the rect without changing its size. + /// + /// If the offset would move the any of the rect's edges outside the bounds of `u16`, the + /// rect's position is clamped to the nearest + fn sub(self, offset: Offset) -> Self { + // Note this cannot be simplified to `self + -offset` because `Offset::MIN` would overflow + let max_x = i32::from(u16::MAX - self.width); + let max_y = i32::from(u16::MAX - self.height); + let x = i32::from(self.x).saturating_sub(offset.x).clamp(0, max_x) as u16; + let y = i32::from(self.y).saturating_sub(offset.y).clamp(0, max_y) as u16; + Self { x, y, ..self } + } +} + +impl AddAssign for Rect { + /// Moves the rect by an offset in place without changing its size. + /// + /// If the offset would move the any of the rect's edges outside the bounds of `u16`, the + /// rect's position is clamped to the nearest edge. + fn add_assign(&mut self, offset: Offset) { + *self = *self + offset; + } +} + +impl SubAssign for Rect { + /// Moves the rect by an offset in place without changing its size. + /// + /// If the offset would move the any of the rect's edges outside the bounds of `u16`, the + /// rect's position is clamped to the nearest edge. + fn sub_assign(&mut self, offset: Offset) { + *self = *self - offset; + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::zero(Rect::new(3, 4, 5, 6), Offset::ZERO, Rect::new(3, 4, 5, 6))] + #[case::positive(Rect::new(3, 4, 5, 6), Offset::new(1, 2), Rect::new(4, 6, 5, 6))] + #[case::negative(Rect::new(3, 4, 5, 6), Offset::new(-1, -2), Rect::new(2, 2, 5, 6))] + #[case::saturate_negative(Rect::new(3, 4, 5, 6), Offset::MIN, Rect::new(0, 0, 5, 6))] + #[case::saturate_positive(Rect::new(3, 4, 5, 6), Offset::MAX, Rect::new(u16::MAX- 5, u16::MAX - 6, 5, 6))] + fn add_offset(#[case] rect: Rect, #[case] offset: Offset, #[case] expected: Rect) { + assert_eq!(rect + offset, expected); + assert_eq!(offset + rect, expected); + } + + #[rstest] + #[case::zero(Rect::new(3, 4, 5, 6), Offset::ZERO, Rect::new(3, 4, 5, 6))] + #[case::positive(Rect::new(3, 4, 5, 6), Offset::new(1, 2), Rect::new(2, 2, 5, 6))] + #[case::negative(Rect::new(3, 4, 5, 6), Offset::new(-1, -2), Rect::new(4, 6, 5, 6))] + #[case::saturate_negative(Rect::new(3, 4, 5, 6), Offset::MAX, Rect::new(0, 0, 5, 6))] + #[case::saturate_positive(Rect::new(3, 4, 5, 6), -Offset::MAX, Rect::new(u16::MAX - 5, u16::MAX - 6, 5, 6))] + fn sub_offset(#[case] rect: Rect, #[case] offset: Offset, #[case] expected: Rect) { + assert_eq!(rect - offset, expected); + } + + #[rstest] + #[case::zero(Rect::new(3, 4, 5, 6), Offset::ZERO, Rect::new(3, 4, 5, 6))] + #[case::positive(Rect::new(3, 4, 5, 6), Offset::new(1, 2), Rect::new(4, 6, 5, 6))] + #[case::negative(Rect::new(3, 4, 5, 6), Offset::new(-1, -2), Rect::new(2, 2, 5, 6))] + #[case::saturate_negative(Rect::new(3, 4, 5, 6), Offset::MIN, Rect::new(0, 0, 5, 6))] + #[case::saturate_positive(Rect::new(3, 4, 5, 6), Offset::MAX, Rect::new(u16::MAX - 5, u16::MAX - 6, 5, 6))] + fn add_assign_offset(#[case] rect: Rect, #[case] offset: Offset, #[case] expected: Rect) { + let mut rect = rect; + rect += offset; + assert_eq!(rect, expected); + } + + #[rstest] + #[case::zero(Rect::new(3, 4, 5, 6), Offset::ZERO, Rect::new(3, 4, 5, 6))] + #[case::positive(Rect::new(3, 4, 5, 6), Offset::new(1, 2), Rect::new(2, 2, 5, 6))] + #[case::negative(Rect::new(3, 4, 5, 6), Offset::new(-1, -2), Rect::new(4, 6, 5, 6))] + #[case::saturate_negative(Rect::new(3, 4, 5, 6), Offset::MAX, Rect::new(0, 0, 5, 6))] + #[case::saturate_positive(Rect::new(3, 4, 5, 6), -Offset::MAX, Rect::new(u16::MAX - 5, u16::MAX - 6, 5, 6))] + fn sub_assign_offset(#[case] rect: Rect, #[case] offset: Offset, #[case] expected: Rect) { + let mut rect = rect; + rect -= offset; + assert_eq!(rect, expected); + } +} diff --git a/ratatui-core/src/layout/size.rs b/ratatui-core/src/layout/size.rs index 28f5d4aa..032c5a0d 100644 --- a/ratatui-core/src/layout/size.rs +++ b/ratatui-core/src/layout/size.rs @@ -54,6 +54,12 @@ impl Size { /// A zero sized Size pub const ZERO: Self = Self::new(0, 0); + /// The minimum possible Size + pub const MIN: Self = Self::ZERO; + + /// The maximum possible Size + pub const MAX: Self = Self::new(u16::MAX, u16::MAX); + /// Create a new `Size` struct pub const fn new(width: u16, height: u16) -> Self { Self { width, height } diff --git a/ratatui-widgets/examples/tabs.rs b/ratatui-widgets/examples/tabs.rs index fcdd37d2..69fc3ff9 100644 --- a/ratatui-widgets/examples/tabs.rs +++ b/ratatui-widgets/examples/tabs.rs @@ -53,7 +53,7 @@ fn render(frame: &mut Frame, selected_tab: usize) { frame.render_widget(title.centered(), top); render_content(frame, main, selected_tab); - render_tabs(frame, main.offset(Offset { x: 1, y: 0 }), selected_tab); + render_tabs(frame, main + Offset::new(1, 0), selected_tab); } /// Render the tabs.