Skip to main content

editor_core/
anchors.rs

1//! Anchored offsets that stay stable under text edits.
2//!
3//! Many editor features need to remember locations in the document (bookmarks, marks, jump list,
4//! snippets, …) and have those locations update when the document is edited.
5//!
6//! This module provides a small, UI-agnostic anchored offset type ([`TextAnchor`]) plus rules for
7//! shifting it through [`crate::TextDelta`] edits.
8
9use crate::TextDelta;
10
11/// Bias controls how an anchor behaves when text is inserted **exactly at** the anchor offset.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13pub enum AnchorBias {
14    /// Keep the anchor at the original offset (anchor stays **before** inserted text).
15    Left,
16    /// Move the anchor to the end of inserted text (anchor stays **after** inserted text).
17    #[default]
18    Right,
19}
20
21/// A character-offset anchor that shifts through edits.
22///
23/// Offsets are expressed as Unicode scalar indices (Rust `char` offsets), consistent with the
24/// rest of `editor-core`’s public API.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub struct TextAnchor {
27    /// Character offset in the document.
28    pub offset: usize,
29    /// Tie-break behavior for insertions at the same offset.
30    pub bias: AnchorBias,
31}
32
33impl TextAnchor {
34    /// Create a new anchor.
35    pub fn new(offset: usize, bias: AnchorBias) -> Self {
36        Self { offset, bias }
37    }
38
39    /// Shift this anchor through a text delta.
40    pub fn apply_delta(&mut self, delta: &TextDelta) {
41        let mut offset = self.offset;
42        for edit in &delta.edits {
43            let start = edit.start;
44            let end = edit.end();
45            let deleted_len = edit.deleted_len();
46            let inserted_len = edit.inserted_len();
47
48            if offset < start {
49                continue;
50            }
51
52            if deleted_len == 0 && offset == start {
53                // Pure insertion at the anchor point.
54                if self.bias == AnchorBias::Right {
55                    offset = start.saturating_add(inserted_len);
56                }
57                continue;
58            }
59
60            if offset < end {
61                // Inside a replaced range: clamp to start or end of inserted text based on bias.
62                offset = match self.bias {
63                    AnchorBias::Left => start,
64                    AnchorBias::Right => start.saturating_add(inserted_len),
65                };
66                continue;
67            }
68
69            // After the replaced range: shift by net length delta.
70            if inserted_len >= deleted_len {
71                offset = offset.saturating_add(inserted_len - deleted_len);
72            } else {
73                offset = offset.saturating_sub(deleted_len - inserted_len);
74            }
75        }
76
77        self.offset = offset.min(delta.after_char_count);
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::TextDeltaEdit;
85
86    fn delta_insert(at: usize, text: &str) -> TextDelta {
87        TextDelta {
88            before_char_count: 0,
89            after_char_count: text.chars().count(),
90            edits: vec![TextDeltaEdit {
91                start: at,
92                deleted_text: String::new(),
93                inserted_text: text.to_string(),
94            }],
95            undo_group_id: None,
96        }
97    }
98
99    #[test]
100    fn insertion_bias_left_stays_before_inserted() {
101        let mut a = TextAnchor::new(0, AnchorBias::Left);
102        a.apply_delta(&delta_insert(0, "abc"));
103        assert_eq!(a.offset, 0);
104    }
105
106    #[test]
107    fn insertion_bias_right_moves_after_inserted() {
108        let mut a = TextAnchor::new(0, AnchorBias::Right);
109        a.apply_delta(&delta_insert(0, "abc"));
110        assert_eq!(a.offset, 3);
111    }
112}