Skip to main content

markdown/
marks.rs

1//! The inline marks an app spells itself.
2//!
3//! [`Mark`](crate::Mark) is closed — bold, italic, strike, code, links — because
4//! every one of those has a spelling CommonMark already reads. Underline,
5//! highlight and a colour do not, so they cannot be variants here without this
6//! crate inventing markdown for everyone. An app that wants them registers a
7//! name and the delimiter that spells it, and the parse and the serializer take
8//! it from there:
9//!
10//! ```
11//! let marks = markdown::Marks::new().with("highlight", "==");
12//! let doc = markdown::parse_with("a ==lit== word", &marks);
13//! assert_eq!(markdown::serialize_with(&doc, &marks), "a ==lit== word");
14//! ```
15//!
16//! The registry is a *parameter* rather than a global because [`parse_with`]
17//! and [`serialize_with`] are pure — the same reason the highlighter is a
18//! function pointer rather than a dependency. [`crate::AppExt::set_marks`] is the gpui-side
19//! half, for the editing surface, which has a `cx` and no other way to know.
20//!
21//! A delimiter markdown already spells (`*`, `_`, `` ` ``, `~`, `[`) is yours to
22//! avoid: CommonMark reads it first and the registration never fires.
23//!
24//! [`parse_with`]: crate::parse_with
25//! [`serialize_with`]: crate::serialize_with
26
27use gpui::{App, FontWeight, Global, Hsla, SharedString};
28use theme::Theme;
29
30/// The custom marks a document is read and written with.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct Marks {
33    entries: Vec<Entry>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub(crate) struct Entry {
38    pub(crate) name: SharedString,
39    pub(crate) delimiter: SharedString,
40}
41
42impl Marks {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Register `name`, spelled by `delimiter` on both sides — `==lit==`.
48    ///
49    /// An empty delimiter, or a second registration of a name or a delimiter
50    /// already taken, is ignored: a registry that can hold two answers for one
51    /// spelling has no reading of a document to offer.
52    pub fn with(
53        mut self,
54        name: impl Into<SharedString>,
55        delimiter: impl Into<SharedString>,
56    ) -> Self {
57        let (name, delimiter) = (name.into(), delimiter.into());
58        let taken = self
59            .entries
60            .iter()
61            .any(|entry| entry.name == name || entry.delimiter == delimiter);
62        if !delimiter.is_empty() && !taken {
63            self.entries.push(Entry { name, delimiter });
64        }
65        self
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.entries.is_empty()
70    }
71
72    /// Every registered name, in the order they were added.
73    pub fn names(&self) -> impl Iterator<Item = &SharedString> {
74        self.entries.iter().map(|entry| &entry.name)
75    }
76
77    /// What spells `name`, for a caller writing its own markdown.
78    pub fn delimiter(&self, name: &str) -> Option<&str> {
79        self.entries
80            .iter()
81            .find(|entry| entry.name == name)
82            .map(|entry| entry.delimiter.as_ref())
83    }
84
85    /// The entries, longest delimiter first — `===` has to be tried before `==`
86    /// or it is never reached.
87    pub(crate) fn sorted(&self) -> Vec<&Entry> {
88        let mut entries: Vec<&Entry> = self.entries.iter().collect();
89        entries.sort_by_key(|entry| std::cmp::Reverse(entry.delimiter.len()));
90        entries
91    }
92
93    pub(crate) fn index(&self, ix: usize) -> Option<&Entry> {
94        self.entries.get(ix)
95    }
96
97    pub(crate) fn position(&self, entry: &Entry) -> Option<usize> {
98        self.entries.iter().position(|row| row == entry)
99    }
100
101    /// What the editing surface reads a document with, or nothing registered.
102    pub(crate) fn of(cx: &App) -> Self {
103        cx.try_global::<Installed>()
104            .map_or_else(Self::default, |installed| installed.0.clone())
105    }
106}
107
108struct Installed(Marks);
109
110impl Global for Installed {}
111
112/// `cx.set_marks(my_marks)` — call once at boot, so the editing
113/// surface reads and writes the same markdown the app's own calls do.
114pub(crate) fn set_marks(cx: &mut App, marks: Marks) {
115    cx.set_global(Installed(marks));
116}
117
118/// How a custom mark paints. Everything a [`gpui::TextRun`] can carry, and
119/// nothing a layout would have to move for.
120#[derive(Clone, Copy, Debug, Default, PartialEq)]
121pub struct MarkPaint {
122    pub color: Option<Hsla>,
123    pub background: Option<Hsla>,
124    pub weight: Option<FontWeight>,
125    pub italic: bool,
126    pub underline: bool,
127    pub strikethrough: bool,
128}
129
130/// What a registered mark looks like. `None` for a name this build does not
131/// paint, which reads as ordinary text.
132pub type Painter = fn(name: &str, theme: &Theme) -> Option<MarkPaint>;
133
134struct InstalledPaint(Painter);
135
136impl Global for InstalledPaint {}
137
138/// `cx.set_mark_paint(my_paint)` — call once at boot. Without it a
139/// custom mark round trips and paints as the text it wraps, which is what an
140/// unknown mark should look like rather than a hole.
141pub(crate) fn set_mark_paint(cx: &mut App, paint: Painter) {
142    cx.set_global(InstalledPaint(paint));
143}
144
145pub(crate) fn paint_of(cx: &App, name: &str, theme: &Theme) -> Option<MarkPaint> {
146    (cx.try_global::<InstalledPaint>()?.0)(name, theme)
147}
148
149/// A reader's highlight colour, by name — the set Apple Books and Notes offer.
150///
151/// A name rather than a colour value, so a highlight saved under one look
152/// paints under another. [`crate::AppExt::set_highlight_paint`] decides what each one paints.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum HighlightColor {
156    #[default]
157    Yellow,
158    Green,
159    Blue,
160    Pink,
161    Purple,
162}
163
164impl HighlightColor {
165    /// Every colour, in the order a picker shows them.
166    pub const ALL: [Self; 5] = [
167        Self::Yellow,
168        Self::Green,
169        Self::Blue,
170        Self::Pink,
171        Self::Purple,
172    ];
173
174    /// The name to store. Stable across releases; [`Self::from_name`] reads it
175    /// back.
176    pub fn name(self) -> &'static str {
177        match self {
178            Self::Yellow => "yellow",
179            Self::Green => "green",
180            Self::Blue => "blue",
181            Self::Pink => "pink",
182            Self::Purple => "purple",
183        }
184    }
185
186    pub fn from_name(name: &str) -> Option<Self> {
187        Self::ALL.into_iter().find(|color| color.name() == name)
188    }
189}
190
191/// The wash a [`HighlightColor`] paints under text.
192pub type HighlightPaint = fn(color: HighlightColor, theme: &Theme) -> Hsla;
193
194struct InstalledHighlight(HighlightPaint);
195
196impl Global for InstalledHighlight {}
197
198/// `cx.set_highlight_paint(my_paint)` — call once at boot. Without
199/// it each colour paints [`default_highlight`].
200pub(crate) fn set_highlight_paint(cx: &mut App, paint: HighlightPaint) {
201    cx.set_global(InstalledHighlight(paint));
202}
203
204pub(crate) fn highlight_paint_of(cx: &App) -> HighlightPaint {
205    cx.try_global::<InstalledHighlight>()
206        .map_or(default_highlight, |installed| installed.0)
207}
208
209/// A [`HighlightColor`] itself, at full strength — Apple's system colour for
210/// the appearance. What a picker paints a swatch in.
211pub fn highlight_solid(color: HighlightColor, theme: &Theme) -> Hsla {
212    let dark = theme.appearance == theme::Appearance::Dark;
213    let hex = match color {
214        HighlightColor::Yellow if dark => 0xFFD60A,
215        HighlightColor::Yellow => 0xFFCC00,
216        HighlightColor::Green if dark => 0x32D74B,
217        HighlightColor::Green => 0x28CD41,
218        HighlightColor::Blue if dark => 0x0A84FF,
219        HighlightColor::Blue => 0x007AFF,
220        HighlightColor::Pink if dark => 0xFF375F,
221        HighlightColor::Pink => 0xFF2D55,
222        HighlightColor::Purple if dark => 0xBF5AF2,
223        HighlightColor::Purple => 0xAF52DE,
224    };
225    gpui::rgb(hex).into()
226}
227
228/// The shipped washes: [`highlight_solid`] at a fixed alpha, translucent so a
229/// selection over one still shows.
230pub fn default_highlight(color: HighlightColor, theme: &Theme) -> Hsla {
231    let alpha = match theme.appearance {
232        theme::Appearance::Dark => 0.32,
233        theme::Appearance::Light => 0.45,
234    };
235    highlight_solid(color, theme).opacity(alpha)
236}