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}