Skip to main content

editor/
anchor.rs

1//! Positions that outlive the edits under them.
2//!
3//! A comment or a highlight is anchored to a range of text, and that range has
4//! to move when the text around it does. What it *says* — a thread's replies,
5//! a highlight's note, who made either — is the app's, the way a link preview
6//! is. What is here is the range, its wash, and what keeps it over the same
7//! words.
8//!
9//! The anchors live in the editor rather than in the app because
10//! [`crate::History`] restores whole-document snapshots: an undo has no delta to
11//! map an anchor through, so the store has to sit where the history can carry
12//! it back.
13//!
14//! Mapping follows the **left-sticky** rule the marks already follow
15//! (`markdown::Text::insert`): text typed at the end of a range joins it, text
16//! typed at the start does not. Both ends take the same arithmetic, so there is
17//! no bias to pick per end and no second rule to keep in step with the first.
18
19use std::ops::Range;
20
21use markdown::{Annotation, Cursor, Part, Selection, Splice};
22
23/// The app's key for a comment or a highlight. Opaque — nothing here looks
24/// inside one.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub struct AnchorId(pub u64);
27
28/// A range in the document, and which wash it paints.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Anchor {
31    pub id: AnchorId,
32    pub range: Selection,
33    pub state: Annotation,
34}
35
36impl Anchor {
37    pub fn new(id: AnchorId, range: Selection) -> Self {
38        Self {
39            id,
40            range,
41            state: Annotation::default(),
42        }
43    }
44
45    /// Whether the words this pointed at are gone.
46    ///
47    /// Detached rather than dropped: whether that reads as "outdated" or as
48    /// "resolved" is the app's call, and deleting it here would take the choice
49    /// away.
50    pub fn detached(&self) -> bool {
51        self.range.is_collapsed()
52    }
53
54    pub(crate) fn map(&mut self, delta: &Delta) {
55        let (start, end) = self.range.ordered();
56        self.range = match (delta.cursor(start), delta.cursor(end)) {
57            (Some(start), Some(end)) => Selection::new(start.min(end), start.max(end)),
58            (surviving, other) => Selection::at(surviving.or(other).unwrap_or_default()),
59        };
60    }
61}
62
63/// What one step of a mutation did to the positions in a document.
64///
65/// Every path through [`crate::Editor::edit`] hands back a list of these, empty
66/// for an edit that moved nothing — so a block operation added later does not
67/// compile until it says what it did. Silent is the one thing it must not be:
68/// an anchor mapped through the wrong shift points at the wrong words and
69/// nothing complains.
70pub(crate) enum Delta {
71    /// Text went in, out, or both.
72    Spliced(Splice),
73    /// A run of blocks left `at`, and arrived at `to` unless it went away.
74    Moved { at: Range<usize>, to: Option<usize> },
75    /// Blocks appeared at `at`, pushing everything from there down.
76    Opened { at: usize, count: usize },
77    /// A row or a column appeared in table `block`, pushing the cells from
78    /// there on.
79    Cells {
80        block: usize,
81        row: Option<usize>,
82        column: Option<usize>,
83    },
84    /// A row or a column left table `block`, taking what was in it and
85    /// pulling the cells after it back.
86    CellsRemoved {
87        block: usize,
88        row: Option<usize>,
89        column: Option<usize>,
90    },
91}
92
93impl Delta {
94    /// Where `at` ends up, and `None` when the block under it went away.
95    fn cursor(&self, at: Cursor) -> Option<Cursor> {
96        match self {
97            Self::Spliced(splice) => {
98                let (start, end) = splice.removed.ordered();
99                if at < start {
100                    return Some(at);
101                }
102                // Inside what went, the seam is the only place left to be.
103                if at <= end {
104                    return Some(splice.caret);
105                }
106                // Past it in the same text, the seam moved by what replaced it;
107                // further down the document, only the block count moved.
108                if at.block == end.block && at.part == end.part {
109                    return Some(Cursor {
110                        offset: splice.caret.offset + (at.offset - end.offset),
111                        ..splice.caret
112                    });
113                }
114                Some(Cursor {
115                    block: at.block.checked_add_signed(splice.blocks)?,
116                    ..at
117                })
118            }
119            Self::Moved { at: span, to } => {
120                if span.contains(&at.block) {
121                    return Some(Cursor {
122                        block: (*to)? + (at.block - span.start),
123                        ..at
124                    });
125                }
126                // Drained before it was put back, and `to` is already counted
127                // against the hole that left — so this is too.
128                let pulled = if at.block >= span.end {
129                    at.block - span.len()
130                } else {
131                    at.block
132                };
133                let block = match to {
134                    Some(to) if pulled >= *to => pulled + span.len(),
135                    _ => pulled,
136                };
137                Some(Cursor { block, ..at })
138            }
139            Self::Cells { block, row, column } => Some(match at.part {
140                Part::Cell { row: r, column: c } if at.block == *block => {
141                    let past = |line: usize, opened: Option<usize>| {
142                        line + usize::from(opened.is_some_and(|opened| line >= opened))
143                    };
144                    Cursor {
145                        part: Part::Cell {
146                            row: past(r, *row),
147                            column: past(c, *column),
148                        },
149                        ..at
150                    }
151                }
152                _ => at,
153            }),
154            Self::CellsRemoved { block, row, column } => match at.part {
155                Part::Cell { row: r, column: c } if at.block == *block => {
156                    if Some(r) == *row || Some(c) == *column {
157                        return None;
158                    }
159                    let past = |line: usize, removed: Option<usize>| {
160                        line - usize::from(removed.is_some_and(|removed| line > removed))
161                    };
162                    Some(Cursor {
163                        part: Part::Cell {
164                            row: past(r, *row),
165                            column: past(c, *column),
166                        },
167                        ..at
168                    })
169                }
170                _ => Some(at),
171            },
172            Self::Opened { at: opened, count } => Some(Cursor {
173                block: if at.block >= *opened {
174                    at.block + count
175                } else {
176                    at.block
177                },
178                ..at
179            }),
180        }
181    }
182}