Skip to main content

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