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/// Each anchor's range in byte offsets into the markdown, `None` where the
29/// markdown has no place for an end.
30pub type SourceRanges = Vec<(AnchorId, Option<Range<usize>>)>;
31
32/// A range in the document, and which wash it paints.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Anchor {
35 pub id: AnchorId,
36 pub range: Selection,
37 pub state: Annotation,
38 /// Whether a press on it does something the app answers. The pointer is a
39 /// hand over an actionable anchor and an I-beam over any other text.
40 pub actionable: bool,
41}
42
43/// An anchor as [`crate::Document::set_source_anchors`] takes it: its range in
44/// byte offsets into the markdown.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SourceAnchor {
47 pub id: AnchorId,
48 pub range: Range<usize>,
49 pub state: Annotation,
50 pub actionable: bool,
51}
52
53impl Anchor {
54 pub fn new(id: AnchorId, range: Selection) -> Self {
55 Self {
56 id,
57 range,
58 state: Annotation::default(),
59 actionable: false,
60 }
61 }
62
63 /// Whether the words this pointed at are gone.
64 ///
65 /// Detached rather than dropped: whether that reads as "outdated" or as
66 /// "resolved" is the app's call, and deleting it here would take the choice
67 /// away.
68 pub fn detached(&self) -> bool {
69 self.range.is_collapsed()
70 }
71
72 pub(crate) fn map(&mut self, delta: &Delta) {
73 let (start, end) = self.range.ordered();
74 self.range = match (delta.cursor(start), delta.cursor(end)) {
75 (Some(start), Some(end)) => Selection::new(start.min(end), start.max(end)),
76 (surviving, other) => Selection::at(surviving.or(other).unwrap_or_default()),
77 };
78 }
79}
80
81/// What one step of a mutation did to the positions in a document.
82///
83/// Every path through [`crate::Editor::edit`] hands back a list of these, empty
84/// for an edit that moved nothing — so a block operation added later does not
85/// compile until it says what it did. Silent is the one thing it must not be:
86/// an anchor mapped through the wrong shift points at the wrong words and
87/// nothing complains.
88pub(crate) enum Delta {
89 CellsMoved {
90 block: usize,
91 from: crate::editor::Line,
92 to: usize,
93 },
94 /// Text went in, out, or both.
95 Spliced(Splice),
96 /// A run of blocks left `at`, and arrived at `to` unless it went away.
97 Moved { at: Range<usize>, to: Option<usize> },
98 /// Blocks appeared at `at`, pushing everything from there down.
99 Opened { at: usize, count: usize },
100 /// A row or a column appeared in table `block`, pushing the cells from
101 /// there on.
102 Cells {
103 block: usize,
104 row: Option<usize>,
105 column: Option<usize>,
106 },
107 /// A row or a column left table `block`, taking what was in it and
108 /// pulling the cells after it back.
109 CellsRemoved {
110 block: usize,
111 row: Option<usize>,
112 column: Option<usize>,
113 },
114}
115
116impl Delta {
117 /// Where `at` ends up, and `None` when the block under it went away.
118 pub(crate) fn cursor(&self, at: Cursor) -> Option<Cursor> {
119 match self {
120 Self::CellsMoved { block, from, to } => {
121 let mut at = at;
122 if at.block == *block
123 && let Part::Cell { row, column } = &mut at.part
124 {
125 let (index, from) = match from {
126 crate::editor::Line::Row(from) => (row, *from),
127 crate::editor::Line::Column(from) => (column, *from),
128 };
129 *index = if *index == from {
130 *to
131 } else if from < *index && *index <= *to {
132 *index - 1
133 } else if *to <= *index && *index < from {
134 *index + 1
135 } else {
136 *index
137 };
138 }
139 Some(at)
140 }
141 Self::Spliced(splice) => {
142 let (start, end) = splice.removed.ordered();
143 if at < start {
144 return Some(at);
145 }
146 // Inside what went, the seam is the only place left to be.
147 if at <= end {
148 return Some(splice.caret);
149 }
150 // Past it in the same text, the seam moved by what replaced it;
151 // further down the document, only the block count moved.
152 if at.block == end.block && at.part == end.part {
153 return Some(Cursor {
154 offset: splice.caret.offset + (at.offset - end.offset),
155 ..splice.caret
156 });
157 }
158 Some(Cursor {
159 block: at.block.checked_add_signed(splice.blocks)?,
160 ..at
161 })
162 }
163 Self::Moved { at: span, to } => {
164 if span.contains(&at.block) {
165 return Some(Cursor {
166 block: (*to)? + (at.block - span.start),
167 ..at
168 });
169 }
170 // Drained before it was put back, and `to` is already counted
171 // against the hole that left — so this is too.
172 let pulled = if at.block >= span.end {
173 at.block - span.len()
174 } else {
175 at.block
176 };
177 let block = match to {
178 Some(to) if pulled >= *to => pulled + span.len(),
179 _ => pulled,
180 };
181 Some(Cursor { block, ..at })
182 }
183 Self::Cells { block, row, column } => Some(match at.part {
184 Part::Cell { row: r, column: c } if at.block == *block => {
185 let past = |line: usize, opened: Option<usize>| {
186 line + usize::from(opened.is_some_and(|opened| line >= opened))
187 };
188 Cursor {
189 part: Part::Cell {
190 row: past(r, *row),
191 column: past(c, *column),
192 },
193 ..at
194 }
195 }
196 _ => at,
197 }),
198 Self::CellsRemoved { block, row, column } => match at.part {
199 Part::Cell { row: r, column: c } if at.block == *block => {
200 if Some(r) == *row || Some(c) == *column {
201 return None;
202 }
203 let past = |line: usize, removed: Option<usize>| {
204 line - usize::from(removed.is_some_and(|removed| line > removed))
205 };
206 Some(Cursor {
207 part: Part::Cell {
208 row: past(r, *row),
209 column: past(c, *column),
210 },
211 ..at
212 })
213 }
214 _ => Some(at),
215 },
216 Self::Opened { at: opened, count } => Some(Cursor {
217 block: if at.block >= *opened {
218 at.block + count
219 } else {
220 at.block
221 },
222 ..at
223 }),
224 }
225 }
226}