Skip to main content

rotulus_layout/
span.rs

1//! Styled text runs.
2//!
3//! A [`Span`] is a byte range over some rendered text plus the style to
4//! draw it with. Spans are the single currency between the parsers
5//! (markdown, the mIRC compat shim) and the layout engine: whatever the
6//! input vocabulary, the output is always a `ParsedText`.
7//!
8//! The important property is that styling is resolved **once, at
9//! append**. xtext re-ran its mIRC state machine over every visible byte
10//! on every render pass (`gtk_xtext_render_str`, xtext.c:3292) *and*
11//! separately built an `ent->slp` run list at append that the render path
12//! then ignored. Here there is one representation, produced once.
13
14use std::ops::Range;
15
16/// Text attribute bits. A plain `u8` rather than a `bitflags` dependency —
17/// there are six of them and they never leave this crate untyped.
18#[derive(Clone, Copy, PartialEq, Eq, Default, Hash)]
19pub struct Attrs(pub u8);
20
21impl Attrs {
22    pub const NONE: Attrs = Attrs(0);
23    pub const BOLD: Attrs = Attrs(1 << 0);
24    pub const ITALIC: Attrs = Attrs(1 << 1);
25    pub const UNDERLINE: Attrs = Attrs(1 << 2);
26    pub const STRIKETHROUGH: Attrs = Attrs(1 << 3);
27    /// Monospace + tinted background: a markdown `` `code` `` span.
28    pub const CODE: Attrs = Attrs(1 << 4);
29    /// Swap foreground and background at render time.
30    pub const REVERSE: Attrs = Attrs(1 << 5);
31
32    #[inline]
33    pub fn contains(self, other: Attrs) -> bool {
34        self.0 & other.0 == other.0
35    }
36
37    #[inline]
38    pub fn union(self, other: Attrs) -> Attrs {
39        Attrs(self.0 | other.0)
40    }
41
42    #[inline]
43    pub fn remove(self, other: Attrs) -> Attrs {
44        Attrs(self.0 & !other.0)
45    }
46
47    #[inline]
48    pub fn is_empty(self) -> bool {
49        self.0 == 0
50    }
51}
52
53impl std::fmt::Debug for Attrs {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        if self.is_empty() {
56            return f.write_str("NONE");
57        }
58        let mut first = true;
59        for (bit, name) in [
60            (Attrs::BOLD, "BOLD"),
61            (Attrs::ITALIC, "ITALIC"),
62            (Attrs::UNDERLINE, "UNDERLINE"),
63            (Attrs::STRIKETHROUGH, "STRIKETHROUGH"),
64            (Attrs::CODE, "CODE"),
65            (Attrs::REVERSE, "REVERSE"),
66        ] {
67            if self.contains(bit) {
68                if !first {
69                    f.write_str("|")?;
70                }
71                f.write_str(name)?;
72                first = false;
73            }
74        }
75        Ok(())
76    }
77}
78
79/// Where a colour comes from.
80///
81/// `Palette` indices 0..31 are the mIRC colors, which is how IRC
82/// formatting (`rotulus-mirc`) addresses them; the slots above are the
83/// theme roles (see `rotulus.h`'s `ROTULUS_PAL_*`). `Rgb` is a literal
84/// color: an IRC extended or hex color, or a per-person nick color.
85#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
86pub enum ColorRef {
87    /// Inherit — the view's default foreground / background.
88    #[default]
89    Default,
90    /// A palette slot.
91    Palette(u8),
92    /// A literal colour, `0x00RRGGBB`.
93    Rgb(u32),
94}
95
96/// Index into [`ParsedText::links`].
97pub type LinkId = u32;
98
99/// How a run of text is drawn.
100#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
101pub struct Style {
102    pub attrs: Attrs,
103    pub fg: ColorRef,
104    pub bg: ColorRef,
105    pub link: Option<LinkId>,
106}
107
108impl Style {
109    pub fn with_attrs(mut self, a: Attrs) -> Style {
110        self.attrs = self.attrs.union(a);
111        self
112    }
113
114    pub fn with_fg(mut self, fg: ColorRef) -> Style {
115        self.fg = fg;
116        self
117    }
118}
119
120/// A styled run: a byte range over the owning text, plus its style.
121///
122/// Ranges are over the **rendered** text, not the source. Markdown
123/// removes its delimiters, so `text` and the input string differ; every
124/// offset in a `Span` indexes `ParsedText::text`.
125#[derive(Clone, PartialEq, Eq, Debug)]
126pub struct Span {
127    pub range: Range<usize>,
128    pub style: Style,
129}
130
131/// A resolved hyperlink.
132#[derive(Clone, PartialEq, Eq, Debug)]
133pub struct Link {
134    /// The destination, already scheme-checked (see
135    /// `crate::markdown::scheme_allowed`).
136    pub href: String,
137    /// The range of visible text that activates it. Kept alongside the
138    /// span's `link` id so a click can report both what was shown and
139    /// where it actually goes — the label and the href are allowed to
140    /// disagree, and the user is entitled to know when they do.
141    pub range: Range<usize>,
142}
143
144/// The output of every parser in this crate.
145#[derive(Clone, PartialEq, Eq, Debug, Default)]
146pub struct ParsedText {
147    /// The text to draw, with all markup removed.
148    pub text: String,
149    /// Styled runs, sorted by start offset and non-overlapping. Runs with
150    /// a wholly default style are omitted rather than materialised, so a
151    /// plain message parses to zero spans.
152    pub spans: Vec<Span>,
153    /// Link targets, indexed by [`LinkId`].
154    pub links: Vec<Link>,
155}
156
157impl ParsedText {
158    /// Give back the spare capacity building left behind.
159    ///
160    /// A parser pushes spans one at a time, so a line with two styled runs
161    /// typically holds room for four; kept for the life of the scrollback,
162    /// that slack is the largest single cost of a row. The buffer compacts
163    /// every message it takes (see `Message::compact`).
164    ///
165    /// By copying into an exact allocation rather than `shrink_to_fit`:
166    /// shrinking in place splits every block into an odd-sized remainder,
167    /// and a scrollback's worth of those fragments the heap badly enough
168    /// that appending runs three times slower and scrolling five. The copy
169    /// frees whole blocks the allocator can hand straight back out.
170    pub fn compact(&mut self) {
171        exact_string(&mut self.text);
172        exact_vec(&mut self.spans);
173        exact_vec(&mut self.links);
174        for l in &mut self.links {
175            exact_string(&mut l.href);
176        }
177    }
178
179    /// A plain, unstyled string.
180    pub fn plain(text: impl Into<String>) -> ParsedText {
181        ParsedText {
182            text: text.into(),
183            spans: Vec::new(),
184            links: Vec::new(),
185        }
186    }
187
188    pub fn is_empty(&self) -> bool {
189        self.text.is_empty()
190    }
191
192    pub fn len(&self) -> usize {
193        self.text.len()
194    }
195
196    /// The style in effect at byte offset `at`.
197    ///
198    /// Linear over spans; callers walking the whole text in order should
199    /// iterate `spans` directly instead. Present for hit-test and test
200    /// assertions, which touch one offset at a time.
201    pub fn style_at(&self, at: usize) -> Style {
202        for s in &self.spans {
203            if s.range.contains(&at) {
204                return s.style;
205            }
206            if s.range.start > at {
207                break;
208            }
209        }
210        Style::default()
211    }
212
213    /// Mark `range` as a hyperlink, returning its id.
214    ///
215    /// Used for *autodetected* URLs, which are found after parsing —
216    /// markdown links come out of the parser already resolved. The
217    /// difference matters: an autodetected URL can land anywhere,
218    /// including straddling existing style runs, so this splits spans at
219    /// the boundaries rather than assuming a clean fit.
220    ///
221    /// Returns `None` for an empty or out-of-bounds range, or one that
222    /// isn't on char boundaries — a detector working in bytes shouldn't
223    /// be able to corrupt the span list.
224    pub fn add_link(&mut self, range: Range<usize>, href: impl Into<String>) -> Option<LinkId> {
225        if range.start >= range.end
226            || range.end > self.text.len()
227            || !self.text.is_char_boundary(range.start)
228            || !self.text.is_char_boundary(range.end)
229        {
230            return None;
231        }
232        let id = self.links.len() as LinkId;
233        self.links.push(Link {
234            href: href.into(),
235            range: range.clone(),
236        });
237
238        let mut out: Vec<Span> = Vec::with_capacity(self.spans.len() + 2);
239        let mut cursor = range.start;
240
241        // Everything before the link, plus the part of any straddling
242        // span that lies before it.
243        for s in &self.spans {
244            if s.range.end <= range.start || s.range.start >= range.end {
245                out.push(s.clone());
246                continue;
247            }
248            if s.range.start < range.start {
249                out.push(Span {
250                    range: s.range.start..range.start,
251                    style: s.style,
252                });
253            }
254            // The overlapping middle keeps its own styling and gains the
255            // link — so a URL inside a bold run stays bold.
256            let ms = s.range.start.max(range.start);
257            let me = s.range.end.min(range.end);
258            if ms > cursor {
259                out.push(Span {
260                    range: cursor..ms,
261                    style: link_style(Style::default(), id),
262                });
263            }
264            if ms < me {
265                out.push(Span {
266                    range: ms..me,
267                    style: link_style(s.style, id),
268                });
269                cursor = me;
270            }
271            if s.range.end > range.end {
272                out.push(Span {
273                    range: range.end..s.range.end,
274                    style: s.style,
275                });
276            }
277        }
278        if cursor < range.end {
279            out.push(Span {
280                range: cursor..range.end,
281                style: link_style(Style::default(), id),
282            });
283        }
284        out.sort_by_key(|s| s.range.start);
285        self.spans = out;
286        self.debug_assert_well_formed();
287        Some(id)
288    }
289
290    /// The link covering byte `at`, if any.
291    pub fn link_at(&self, at: usize) -> Option<&Link> {
292        let id = self.style_at(at).link?;
293        self.links.get(id as usize)
294    }
295
296    /// Panics (in debug) unless the spans are sorted, non-empty,
297    /// non-overlapping and inside the text. Called by the parsers'
298    /// tests; cheap enough to also call in debug builds of callers.
299    pub fn debug_assert_well_formed(&self) {
300        if !cfg!(debug_assertions) {
301            return;
302        }
303        let mut prev_end = 0usize;
304        for s in &self.spans {
305            assert!(s.range.start < s.range.end, "empty span {:?}", s.range);
306            assert!(
307                s.range.start >= prev_end,
308                "overlapping or unsorted spans: {:?} after end {}",
309                s.range,
310                prev_end
311            );
312            assert!(
313                s.range.end <= self.text.len(),
314                "span {:?} past text len {}",
315                s.range,
316                self.text.len()
317            );
318            assert!(
319                self.text.is_char_boundary(s.range.start)
320                    && self.text.is_char_boundary(s.range.end),
321                "span {:?} not on char boundaries",
322                s.range
323            );
324            prev_end = s.range.end;
325        }
326    }
327}
328
329/// Accumulates text + styled runs, coalescing adjacent equal styles.
330///
331/// The parsers all build their output through this so that
332/// `**a**` + `**b**` written adjacently produces one bold span rather
333/// than two, which keeps the shaped-run count down in the layout engine.
334#[derive(Default)]
335pub(crate) struct SpanBuilder {
336    text: String,
337    spans: Vec<Span>,
338    links: Vec<Link>,
339}
340
341impl SpanBuilder {
342    pub(crate) fn new() -> SpanBuilder {
343        SpanBuilder::default()
344    }
345
346    pub(crate) fn len(&self) -> usize {
347        self.text.len()
348    }
349
350    /// Append `s` styled with `style`, merging into the previous run when
351    /// the style matches and the runs abut.
352    pub(crate) fn push(&mut self, s: &str, style: Style) {
353        if s.is_empty() {
354            return;
355        }
356        let start = self.text.len();
357        self.text.push_str(s);
358        let end = self.text.len();
359
360        if style == Style::default() {
361            return;
362        }
363        if let Some(last) = self.spans.last_mut() {
364            if last.range.end == start && last.style == style {
365                last.range.end = end;
366                return;
367            }
368        }
369        self.spans.push(Span {
370            range: start..end,
371            style,
372        });
373    }
374
375    /// Reserve a link id before its label has been emitted.
376    ///
377    /// The id has to exist first so it can be carried in the label's
378    /// [`Style`]; the visible range is only known once the label is
379    /// pushed, so it starts empty and is filled in by
380    /// [`SpanBuilder::set_link_range`].
381    pub(crate) fn reserve_link(&mut self, href: String) -> LinkId {
382        self.links.push(Link { href, range: 0..0 });
383        (self.links.len() - 1) as LinkId
384    }
385
386    pub(crate) fn set_link_range(&mut self, id: LinkId, range: Range<usize>) {
387        if let Some(l) = self.links.get_mut(id as usize) {
388            l.range = range;
389        }
390    }
391
392    pub(crate) fn finish(self) -> ParsedText {
393        ParsedText {
394            text: self.text,
395            spans: self.spans,
396            links: self.links,
397        }
398    }
399}
400
401/// A style with a link attached. Underlined so links read as links
402/// regardless of what colour the message already carries.
403fn link_style(base: Style, id: LinkId) -> Style {
404    let mut s = base;
405    s.link = Some(id);
406    s.attrs = s.attrs.union(Attrs::UNDERLINE);
407    s
408}
409
410/// Move `s` into an allocation exactly its length, if it has spare room.
411/// See [`ParsedText::compact`] for why a copy and not `shrink_to_fit`.
412pub(crate) fn exact_string(s: &mut String) {
413    if s.capacity() > s.len() {
414        *s = s.as_str().to_owned();
415    }
416}
417
418/// [`exact_string`] for a vector.
419pub(crate) fn exact_vec<T>(v: &mut Vec<T>) {
420    if v.capacity() > v.len() {
421        let mut exact = Vec::with_capacity(v.len());
422        exact.append(v);
423        *v = exact;
424    }
425}