hjkl_buffer/position.rs
1/// A `(row, col)` location inside a [`crate::Buffer`].
2///
3/// - `row` is zero-based, in **logical lines** (newline-separated). Wrapping
4/// is a render-only concern; no `Position` ever points at a display line.
5/// - `col` is zero-based, **char index within the line** — not bytes, not
6/// graphemes, not display columns. Width-aware motions go through helpers in
7/// `motion.rs`; do not synthesize `Position` from a column count without
8/// consulting them. The accompanying [`Position::byte_offset`] helper
9/// converts a char-index `col` back to a byte offset when slicing the
10/// underlying `String`.
11///
12/// ## Bounds
13///
14/// A `Position` is **valid** for a buffer iff:
15///
16/// - `row < buffer.lines().len()`
17/// - `col <= buffer.line(row).unwrap().chars().count()` (one past the last
18/// char is allowed — insert mode lives there).
19///
20/// Pass an out-of-bounds `Position` to [`crate::Buffer::set_cursor`] and the
21/// buffer clamps to the nearest valid one via
22/// [`crate::Buffer::clamp_position`]. Pass one to
23/// [`crate::Buffer::apply_edit`] and the edit is rejected (returns the no-op
24/// inverse).
25///
26/// ## Sticky column
27///
28/// [`crate::Buffer`] tracks an optional sticky column for `j` / `k` motions:
29/// the target column to land in once the cursor reaches a line long enough to
30/// honor it. Never reset it manually outside motion code — it survives
31/// [`crate::Buffer::set_cursor`] for that exact reason.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
33pub struct Position {
34 pub row: usize,
35 pub col: usize,
36}
37
38impl Position {
39 pub const fn new(row: usize, col: usize) -> Self {
40 Self { row, col }
41 }
42
43 /// Byte offset of `self.col` (a char index) into `line`. Returns
44 /// `line.len()` when `col` is at or past the end of the line —
45 /// matches `String::insert` / `replace_range` boundary semantics.
46 pub fn byte_offset(self, line: &str) -> usize {
47 line.char_indices()
48 .nth(self.col)
49 .map(|(b, _)| b)
50 .unwrap_or(line.len())
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::Position;
57
58 #[test]
59 fn byte_offset_ascii() {
60 assert_eq!(Position::new(0, 0).byte_offset("hello"), 0);
61 assert_eq!(Position::new(0, 3).byte_offset("hello"), 3);
62 assert_eq!(Position::new(0, 5).byte_offset("hello"), 5);
63 // Past end clamps at line length so callers can use it as an
64 // insertion point without bounds-check ceremony.
65 assert_eq!(Position::new(0, 99).byte_offset("hello"), 5);
66 }
67
68 #[test]
69 fn byte_offset_utf8() {
70 // "tablé" — 'é' is 2 bytes in UTF-8.
71 let line = "tablé";
72 assert_eq!(Position::new(0, 4).byte_offset(line), 4);
73 assert_eq!(Position::new(0, 5).byte_offset(line), 6);
74 }
75
76 #[test]
77 fn ord_is_row_major() {
78 assert!(Position::new(0, 5) < Position::new(1, 0));
79 assert!(Position::new(2, 0) > Position::new(1, 999));
80 assert!(Position::new(1, 3) < Position::new(1, 4));
81 }
82}