Skip to main content

monospace_core/
geometry.rs

1//! Positions and sizes, kept as separate types so the compiler refuses one where the other
2//! belongs: a position is signed and may be negative, a size never is, and one rectangle carrying
3//! both pairs would make the two interchangeable where the distinction matters.
4//!
5//! **The widths are the terminal's; [`docs/model.md`](../../../docs/model.md) §2 says what they are
6//! and what the range costs.**
7
8/// An absolute position in the plane a buffer occupies.
9///
10/// Coordinates may be negative: nothing in this crate treats the origin as a boundary.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Pos {
13    /// Grows to the right.
14    pub x: i16,
15    /// Grows downward.
16    pub y: i16,
17}
18
19/// A position **inside one window**, counted from its top-left corner.
20///
21/// Not the same thing as a [`Pos`], and kept apart for the same reason the two conventions a cell
22/// carries are: a `Pos` is a point in the plane and may be negative, an `Offset` is a column and a
23/// row within a rectangle and never is. Which one a method takes is the question it is asking —
24/// a figure is placed at a `Pos`, a click arrives as an `Offset` — so the two are separate types
25/// rather than one rectangle's worth of interchangeable numbers.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Offset {
28    /// Grows to the right, from the window's own leftmost column.
29    pub x: u16,
30    /// Grows downward, from the window's own topmost row.
31    pub y: u16,
32}
33
34/// The size of a window or a rendered area, in cells.
35///
36/// A width or height of zero is allowed: it describes an area with no positions, rather than
37/// being rejected.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Size {
40    /// Never negative, by the type.
41    pub width: u16,
42    /// Never negative, by the type.
43    pub height: u16,
44}
45
46/// A rectangle in the plane: where it starts and how large it is.
47///
48/// **An origin and a [`Size`], and not a [`Footprint`]**, because the two answer different questions:
49/// a window is chosen before anything is drawn and may hold no position at all, while a footprint
50/// exists only once a stamp has reached somewhere. A [`Buffer`](crate::Buffer) is given one of
51/// these, and `render` is asked for one, so the same rectangle travels through both without being
52/// taken apart into two arguments.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Window {
55    /// The top-left corner, in the plane.
56    pub origin: Pos,
57    /// How far the window reaches from `origin`, right and down.
58    pub size: Size,
59}
60
61impl Window {
62    /// Whether `at` falls inside this window.
63    ///
64    /// The right and bottom edges are outside: a window of width `w` holds columns `origin.x` to
65    /// `origin.x + w - 1`, which is the rectangle a buffer stamps into.
66    #[must_use]
67    pub fn contains(self, at: Pos) -> bool {
68        let Some(dx) =
69            at.x.checked_sub(self.origin.x)
70                .and_then(|d| u16::try_from(d).ok())
71        else {
72            return false;
73        };
74        let Some(dy) =
75            at.y.checked_sub(self.origin.y)
76                .and_then(|d| u16::try_from(d).ok())
77        else {
78            return false;
79        };
80        dx < self.size.width && dy < self.size.height
81    }
82
83    /// The position `at` reaches, counted from this window's top-left corner, or `None` when the
84    /// origin and the offset cannot be added.
85    ///
86    /// The offset is the one a caller already holds — a click arrives as a column and a row of what
87    /// was rendered — so nothing is added before this translation, and the window's `origin` takes
88    /// no other part in the answer.
89    #[must_use]
90    pub fn position(self, at: Offset) -> Option<Pos> {
91        Some(Pos {
92            x: self.origin.x.checked_add_unsigned(at.x)?,
93            y: self.origin.y.checked_add_unsigned(at.y)?,
94        })
95    }
96}
97
98/// The rectangle a drawing reaches: the smallest and largest coordinates stamped into it.
99///
100/// **Two positions rather than an origin and a [`Size`]**, because `min` is a point in the plane and
101/// may be negative while the distance between the two is a magnitude: a span of `u16::MAX + 1` cells
102/// exists and no `Size` holds it, and a caller that wants the distance can derive it and decide what
103/// to do about the one span that does not fit. A drawing that stamped nothing reaches nothing, which
104/// is a state of its own rather than a rectangle at the origin.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct Footprint {
107    /// The smallest coordinate stamped, on both axes: the top-left corner, since `y` grows down.
108    pub min: Pos,
109    /// The largest coordinate stamped, on both axes.
110    pub max: Pos,
111}
112
113impl Footprint {
114    /// This footprint grown to hold `at`.
115    ///
116    /// The fold a [`FootprintSink`](crate::FootprintSink) performs once per stamp, kept here so
117    /// that "smallest" and "largest" are written once rather than at every caller that grows a
118    /// footprint.
119    #[must_use]
120    pub(crate) fn including(self, at: Pos) -> Self {
121        Self {
122            min: Pos {
123                x: self.min.x.min(at.x),
124                y: self.min.y.min(at.y),
125            },
126            max: Pos {
127                x: self.max.x.max(at.x),
128                y: self.max.y.max(at.y),
129            },
130        }
131    }
132}
133
134/// A way to move across the plane: up, right, down or left.
135///
136/// Not the same thing as a `Side`, which is a place on a cell rather than a way to move — see
137/// _Vocabulary_ in [`docs/model.md`](../../../docs/model.md). A figure reasons in directions; a
138/// fragment is told sides, and the translation between the two happens where a figure places its
139/// pieces.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Direction {
142    /// Toward smaller `y`.
143    Up,
144    /// Toward larger `x`.
145    Right,
146    /// Toward larger `y`.
147    Down,
148    /// Toward smaller `x`.
149    Left,
150}
151
152/// Whether a run of cells lies along a row or a column.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Orientation {
155    /// Along a row: positions differ in `x`.
156    Horizontal,
157    /// Along a column: positions differ in `y`.
158    Vertical,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{Offset, Pos, Size, Window};
164
165    fn window(origin: Pos, width: u16, height: u16) -> Window {
166        Window {
167            origin,
168            size: Size { width, height },
169        }
170    }
171
172    /// A window of no size holds no position, not even the one at its corner.
173    #[test]
174    fn a_window_of_no_size_contains_no_position() {
175        let empty = window(Pos { x: 3, y: 4 }, 0, 0);
176
177        assert!(!empty.contains(Pos { x: 3, y: 4 }));
178        assert!(!empty.contains(Pos { x: 4, y: 4 }));
179    }
180
181    /// A negative origin is a corner like any other: it is inside, the position before it is not,
182    /// and the far edges are exclusive.
183    #[test]
184    fn a_window_contains_its_corner_and_stops_before_its_far_edges() {
185        let at_negative = window(Pos { x: -2, y: -3 }, 2, 2);
186
187        assert!(at_negative.contains(Pos { x: -2, y: -3 }));
188        assert!(at_negative.contains(Pos { x: -1, y: -2 }));
189        assert!(!at_negative.contains(Pos { x: -3, y: -3 }));
190        assert!(!at_negative.contains(Pos { x: -2, y: -4 }));
191        assert!(!at_negative.contains(Pos { x: 0, y: -3 }));
192        assert!(!at_negative.contains(Pos { x: -2, y: -1 }));
193    }
194
195    /// The corner offset reaches the origin, and every other offset adds to it.
196    #[test]
197    fn an_offset_is_counted_from_the_windows_corner() {
198        let window = window(Pos { x: 5, y: -4 }, 10, 10);
199
200        assert_eq!(
201            window.position(Offset { x: 0, y: 0 }),
202            Some(Pos { x: 5, y: -4 })
203        );
204        assert_eq!(
205            window.position(Offset { x: 2, y: 3 }),
206            Some(Pos { x: 7, y: -1 })
207        );
208    }
209
210    /// An offset the origin cannot reach is `None`, on either axis, and the largest one that does
211    /// fit is still a position.
212    #[test]
213    fn an_offset_the_origin_cannot_reach_is_none() {
214        let window = window(Pos { x: i16::MAX, y: 0 }, 1, 1);
215
216        assert_eq!(window.position(Offset { x: 1, y: 0 }), None);
217        assert_eq!(window.position(Offset { x: 0, y: u16::MAX }), None);
218        assert_eq!(
219            window.position(Offset {
220                x: 0,
221                y: i16::MAX as u16
222            }),
223            Some(Pos {
224                x: i16::MAX,
225                y: i16::MAX
226            })
227        );
228    }
229}