scrive_core/coords.rs
1//! Buffer-space coordinates and the primitives every position shares.
2//!
3//! Two ideas live here: the [`Point`] coordinate (row + byte column) and the
4//! [`Bias`] attachment side. Cell columns — the *display* space — are a
5//! separate type in the display map; the two spaces are distinct types, so
6//! mixing a byte column with a display cell is a compile error rather than a
7//! silent off-by-column.
8
9/// Attachment side for a position that sits *between* two characters.
10///
11/// `Left` means "stick to the character before me"; `Right` means "…after me".
12/// It is the single shared vocabulary for clipping to char boundaries (here),
13/// tracked-range stickiness (a named pair of biases), and hit-testing.
14#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
15pub enum Bias {
16 /// Bind to the character before the position.
17 Left,
18 /// Bind to the character after the position.
19 Right,
20}
21
22/// A buffer-space position: zero-based `row`, and a **byte** `col` within that
23/// row's text (the `\n` is not part of the row).
24///
25/// Columns are byte offsets, not cells and not grapheme clusters — cell columns
26/// are [`DisplayPoint`](crate) territory, and grapheme-cluster navigation is a
27/// deliberate non-goal. Ordering is row-major, so `Point`s compare in document
28/// order.
29#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
30pub struct Point {
31 /// Zero-based line index.
32 pub row: u32,
33 /// Byte offset within the row (excludes the trailing `\n`).
34 pub col: u32,
35}
36
37impl Point {
38 /// A `Point` at `(row, col)`.
39 #[must_use]
40 pub const fn new(row: u32, col: u32) -> Self {
41 Self { row, col }
42 }
43
44 /// The origin, `(0, 0)`.
45 pub const ZERO: Self = Self { row: 0, col: 0 };
46}
47
48/// Snap a byte offset to the nearest UTF-8 character boundary of `text`,
49/// choosing the direction from `bias`.
50///
51/// If `offset` already lands on a boundary (or on either end of `text`) it is
52/// returned unchanged. Otherwise `Bias::Left` moves to the boundary at or
53/// before `offset`, `Bias::Right` to the boundary at or after. Offsets past the
54/// end clamp to `text.len()`. This is the char-boundary half of the buffer's
55/// clip rules, which keep every stored offset on a valid boundary.
56#[must_use]
57pub(crate) fn snap_char_boundary(text: &str, offset: u32, bias: Bias) -> u32 {
58 let len = text.len() as u32;
59 if offset >= len {
60 return len;
61 }
62 let mut o = offset as usize;
63 if text.is_char_boundary(o) {
64 return offset;
65 }
66 match bias {
67 Bias::Left => {
68 while !text.is_char_boundary(o) {
69 o -= 1;
70 }
71 }
72 Bias::Right => {
73 while !text.is_char_boundary(o) {
74 o += 1;
75 }
76 }
77 }
78 o as u32
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn points_order_row_major() {
87 assert!(Point::new(0, 5) < Point::new(1, 0));
88 assert!(Point::new(1, 2) < Point::new(1, 3));
89 assert_eq!(Point::ZERO, Point::new(0, 0));
90 }
91
92 #[test]
93 fn snap_is_identity_on_ascii_and_ends() {
94 let s = "hello";
95 for o in 0..=s.len() as u32 {
96 assert_eq!(snap_char_boundary(s, o, Bias::Left), o);
97 assert_eq!(snap_char_boundary(s, o, Bias::Right), o);
98 }
99 }
100
101 #[test]
102 fn snap_moves_off_multibyte_interior() {
103 // "é" is 2 bytes (0xC3 0xA9): "aé" = bytes [a=0][é=1,2], len 3.
104 let s = "aé";
105 assert_eq!(s.len(), 3);
106 // Offset 2 is inside 'é'.
107 assert_eq!(snap_char_boundary(s, 2, Bias::Left), 1);
108 assert_eq!(snap_char_boundary(s, 2, Bias::Right), 3);
109 // Boundaries are untouched.
110 assert_eq!(snap_char_boundary(s, 1, Bias::Left), 1);
111 assert_eq!(snap_char_boundary(s, 3, Bias::Right), 3);
112 }
113}