Skip to main content

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}