fiberplane_models/
formatting.rs

1use crate::{notebooks::Label, timestamps::Timestamp};
2#[cfg(feature = "fp-bindgen")]
3use fp_bindgen::prelude::Serializable;
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use typed_builder::TypedBuilder;
7
8/// Formatting to be applied in order to turn plain-text into rich-text.
9///
10/// The vector consists of tuples, each containing a character offset and an
11/// annotation. The vector must be sorted by offset (the order of annotations at
12/// the same offset is undefined).
13pub type Formatting = Vec<AnnotationWithOffset>;
14
15/// Newtype representing `(offset, Annotation)` tuples.
16///
17/// Used inside the `Formatting` vector.
18#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, TypedBuilder)]
19#[cfg_attr(
20    feature = "fp-bindgen",
21    derive(Serializable),
22    fp(rust_module = "fiberplane_models::formatting")
23)]
24#[non_exhaustive]
25#[serde(rename_all = "camelCase")]
26pub struct AnnotationWithOffset {
27    pub offset: u32,
28    #[serde(flatten)]
29    pub annotation: Annotation,
30}
31
32impl AnnotationWithOffset {
33    pub fn new(offset: u32, annotation: Annotation) -> Self {
34        Self { offset, annotation }
35    }
36
37    /// Translates the offset of the annotation with the given delta.
38    pub fn translate(&self, delta: i64) -> Self {
39        Self {
40            offset: (self.offset as i64 + delta) as u32,
41            annotation: self.annotation.clone(),
42        }
43    }
44}
45
46/// A rich-text annotation.
47///
48/// Annotations are typically found inside a `Formatting` vector.
49#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
50#[cfg_attr(
51    feature = "fp-bindgen",
52    derive(Serializable),
53    fp(rust_module = "fiberplane_models::formatting")
54)]
55#[non_exhaustive]
56#[serde(rename_all = "snake_case", tag = "type")]
57pub enum Annotation {
58    StartBold,
59    EndBold,
60    StartCode,
61    EndCode,
62    StartHighlight,
63    EndHighlight,
64    StartItalics,
65    EndItalics,
66    #[serde(rename_all = "camelCase")]
67    StartLink {
68        url: String,
69    },
70    EndLink,
71    Mention(Mention),
72    Timestamp {
73        #[serde(with = "time::serde::rfc3339")]
74        timestamp: OffsetDateTime,
75    },
76    StartStrikethrough,
77    EndStrikethrough,
78    StartUnderline,
79    EndUnderline,
80    Label(Label),
81}
82
83impl Annotation {
84    /// Returns the opposite of an annotation for the purpose of toggling the
85    /// formatting.
86    ///
87    /// Returns `None` if the annotation is not part of a pair, or if the
88    /// formatting cannot be toggled without more information.
89    pub fn toggle_opposite(&self) -> Option<Annotation> {
90        match self {
91            Annotation::StartBold => Some(Annotation::EndBold),
92            Annotation::EndBold => Some(Annotation::StartBold),
93            Annotation::StartCode => Some(Annotation::EndCode),
94            Annotation::EndCode => Some(Annotation::StartCode),
95            Annotation::StartHighlight => Some(Annotation::EndHighlight),
96            Annotation::EndHighlight => Some(Annotation::StartHighlight),
97            Annotation::StartItalics => Some(Annotation::EndItalics),
98            Annotation::EndItalics => Some(Annotation::StartItalics),
99            Annotation::StartLink { .. } => Some(Annotation::EndLink),
100            Annotation::EndLink => None,
101            Annotation::Mention(_) => None,
102            Annotation::Timestamp { .. } => None,
103            Annotation::StartStrikethrough => Some(Annotation::EndStrikethrough),
104            Annotation::EndStrikethrough => Some(Annotation::StartStrikethrough),
105            Annotation::StartUnderline => Some(Annotation::EndUnderline),
106            Annotation::EndUnderline => Some(Annotation::StartUnderline),
107            Annotation::Label(_) => None,
108        }
109    }
110}
111
112/// A struct that represents all the formatting that is active at any given
113/// character offset.
114#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq, TypedBuilder)]
115#[cfg_attr(
116    feature = "fp-bindgen",
117    derive(Serializable),
118    fp(rust_module = "fiberplane_models::formatting")
119)]
120#[non_exhaustive]
121#[serde(rename_all = "camelCase")]
122pub struct ActiveFormatting {
123    pub bold: bool,
124    pub code: bool,
125    pub highlight: bool,
126    pub italics: bool,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub link: Option<String>,
129    pub strikethrough: bool,
130    pub underline: bool,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub label: Option<Label>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub timestamp: Option<Timestamp>,
135}
136
137impl ActiveFormatting {
138    pub fn with_bold(&self, bold: bool) -> Self {
139        Self {
140            bold,
141            ..self.clone()
142        }
143    }
144
145    pub fn with_code(&self, code: bool) -> Self {
146        Self {
147            code,
148            ..self.clone()
149        }
150    }
151
152    pub fn with_highlight(&self, highlight: bool) -> Self {
153        Self {
154            highlight,
155            ..self.clone()
156        }
157    }
158
159    pub fn with_italics(&self, italics: bool) -> Self {
160        Self {
161            italics,
162            ..self.clone()
163        }
164    }
165
166    pub fn with_link(&self, link: impl Into<Option<String>>) -> Self {
167        Self {
168            link: link.into(),
169            ..self.clone()
170        }
171    }
172
173    pub fn with_strikethrough(&self, strikethrough: bool) -> Self {
174        Self {
175            strikethrough,
176            ..self.clone()
177        }
178    }
179
180    pub fn with_underline(&self, underline: bool) -> Self {
181        Self {
182            underline,
183            ..self.clone()
184        }
185    }
186
187    pub fn with_label(&self, label: impl Into<Option<Label>>) -> Self {
188        Self {
189            label: label.into(),
190            ..self.clone()
191        }
192    }
193
194    pub fn with_timestamp(&self, timestamp: impl Into<Option<Timestamp>>) -> Self {
195        Self {
196            timestamp: timestamp.into(),
197            ..self.clone()
198        }
199    }
200
201    /// Returns a list of annotations that should be inserted to activate
202    /// this formatting compared to a reference formatting.
203    pub fn annotations_for_toggled_formatting(&self, reference: &Self) -> Vec<Annotation> {
204        let mut annotations = Vec::new();
205        if self.bold != reference.bold {
206            annotations.push(if self.bold {
207                Annotation::StartBold
208            } else {
209                Annotation::EndBold
210            });
211        }
212        if self.code != reference.code {
213            annotations.push(if self.code {
214                Annotation::StartCode
215            } else {
216                Annotation::EndCode
217            });
218        }
219        if self.highlight != reference.highlight {
220            annotations.push(if self.highlight {
221                Annotation::StartHighlight
222            } else {
223                Annotation::EndHighlight
224            });
225        }
226        if self.italics != reference.italics {
227            annotations.push(if self.italics {
228                Annotation::StartItalics
229            } else {
230                Annotation::EndItalics
231            });
232        }
233        if self.link != reference.link {
234            annotations.push(if let Some(url) = self.link.as_ref() {
235                Annotation::StartLink { url: url.clone() }
236            } else {
237                Annotation::EndLink
238            });
239        }
240        if self.strikethrough != reference.strikethrough {
241            annotations.push(if self.strikethrough {
242                Annotation::StartStrikethrough
243            } else {
244                Annotation::EndStrikethrough
245            });
246        }
247        if self.underline != reference.underline {
248            annotations.push(if self.underline {
249                Annotation::StartUnderline
250            } else {
251                Annotation::EndUnderline
252            });
253        }
254        if self.label != reference.label {
255            if let Some(label) = self.label.as_ref() {
256                annotations.push(Annotation::Label(label.clone()))
257            }
258        }
259        if self.timestamp != reference.timestamp {
260            if let Some(timestamp) = self.timestamp {
261                annotations.push(Annotation::Timestamp {
262                    timestamp: *timestamp,
263                })
264            }
265        }
266        annotations
267    }
268
269    /// Returns whether the given annotation is active in this struct.
270    pub fn contains(&self, annotation: &Annotation) -> bool {
271        match annotation {
272            Annotation::StartBold => self.bold,
273            Annotation::EndBold => !self.bold,
274            Annotation::StartCode => self.code,
275            Annotation::EndCode => !self.code,
276            Annotation::StartHighlight => self.highlight,
277            Annotation::EndHighlight => !self.highlight,
278            Annotation::StartItalics => self.italics,
279            Annotation::EndItalics => !self.italics,
280            Annotation::StartLink { .. } => self.link.is_some(),
281            Annotation::EndLink => self.link.is_none(),
282            Annotation::Mention(_) => false,
283            Annotation::Timestamp { .. } => self.timestamp.is_some(),
284            Annotation::StartStrikethrough => self.strikethrough,
285            Annotation::EndStrikethrough => !self.strikethrough,
286            Annotation::StartUnderline => self.underline,
287            Annotation::EndUnderline => !self.underline,
288            Annotation::Label(_) => self.label.is_some(),
289        }
290    }
291}
292
293/// Annotation for the mention of a user.
294///
295/// Mentions do not have a start and end offset. Instead, they occur at the
296/// start offset only and are expected to run up to the end of the name of
297/// the mentioned user. If however, for unforeseen reasons, the plain text
298/// being annotated does not align with the name inside the mention, the
299/// mention will stop at the first non-matching character. Mentions for
300/// which the first character of the name does not align must be ignored in
301/// their entirety.
302#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, TypedBuilder)]
303#[cfg_attr(
304    feature = "fp-bindgen",
305    derive(Serializable),
306    fp(rust_module = "fiberplane_models::formatting")
307)]
308#[non_exhaustive]
309#[serde(rename_all = "camelCase")]
310pub struct Mention {
311    #[builder(setter(into))]
312    pub name: String,
313    #[builder(setter(into))]
314    pub user_id: String,
315}
316
317/// Finds the first index at which an annotation can be found for the given
318/// offset, or the next existing offset in case the exact offset cannot be
319/// found.
320///
321/// Returns the length of the range if no annotation for the offset can be
322/// found.
323pub fn first_annotation_index_for_offset(range: &[AnnotationWithOffset], offset: u32) -> usize {
324    let mut index = annotation_insertion_index(range, offset);
325    // Make sure we return the first in case of multiple hits:
326    while index > 0 && range[index - 1].offset == offset {
327        index -= 1;
328    }
329
330    index
331}
332
333#[test]
334fn test_first_annotation_index_for_offset() {
335    let formatting = vec![
336        AnnotationWithOffset::new(30, Annotation::StartBold),
337        AnnotationWithOffset::new(30, Annotation::StartItalics),
338        AnnotationWithOffset::new(94, Annotation::EndBold),
339        AnnotationWithOffset::new(94, Annotation::EndItalics),
340    ];
341
342    assert_eq!(first_annotation_index_for_offset(&formatting, 10), 0);
343    assert_eq!(first_annotation_index_for_offset(&formatting, 30), 0);
344    assert_eq!(first_annotation_index_for_offset(&formatting, 31), 2);
345    assert_eq!(first_annotation_index_for_offset(&formatting, 94), 2);
346    assert_eq!(first_annotation_index_for_offset(&formatting, 95), 4);
347}
348
349/// Finds the first index at which an annotation can be found for an offset
350/// higher than the given offset.
351///
352/// Returns the length of the range if no annotations for higher offsets can be
353/// found.
354pub fn first_annotation_index_beyond_offset(range: &[AnnotationWithOffset], offset: u32) -> usize {
355    let mut index = annotation_insertion_index(range, offset);
356    // Make sure we step over any potential hits:
357    while index < range.len() && range[index].offset == offset {
358        index += 1;
359    }
360
361    index
362}
363
364#[test]
365fn test_first_annotation_index_beyond_offset() {
366    let formatting = vec![
367        AnnotationWithOffset::new(30, Annotation::StartBold),
368        AnnotationWithOffset::new(30, Annotation::StartItalics),
369        AnnotationWithOffset::new(94, Annotation::EndBold),
370        AnnotationWithOffset::new(94, Annotation::EndItalics),
371    ];
372
373    assert_eq!(first_annotation_index_beyond_offset(&formatting, 10), 0);
374    assert_eq!(first_annotation_index_beyond_offset(&formatting, 30), 2);
375    assert_eq!(first_annotation_index_beyond_offset(&formatting, 31), 2);
376    assert_eq!(first_annotation_index_beyond_offset(&formatting, 94), 4);
377    assert_eq!(first_annotation_index_beyond_offset(&formatting, 95), 4);
378}
379
380/// Finds the correct insertion index for an annotation at the given offset
381/// inside of a range.
382pub fn annotation_insertion_index(range: &[AnnotationWithOffset], offset: u32) -> usize {
383    match range.binary_search_by(|annotation| annotation.offset.cmp(&offset)) {
384        Ok(index) => index,
385        Err(insertion_index) => insertion_index,
386    }
387}
388
389/// Translates all offsets in a range of formatting annotations with the given
390/// delta.
391#[must_use]
392pub fn translate(range: &[AnnotationWithOffset], delta: i64) -> Formatting {
393    range
394        .iter()
395        .map(|annotation| annotation.translate(delta))
396        .collect()
397}