Skip to main content

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}