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