Skip to main content

rotulus_mirc/
lib.rs

1//! IRC formatting codes, as Rotulus styled text.
2//!
3//! IRC carries its styling in-band: a byte like `0x02` toggles bold, and
4//! `0x03` followed by digits sets a color. Rotulus never interprets bytes
5//! like these itself — a view that did would let anyone who can send it
6//! text restyle its transcript — so an IRC client decides which messages
7//! may carry formatting and converts those here, into ordinary styled
8//! runs, before appending.
9//!
10//! The vocabulary is the one documented at
11//! <https://modern.ircdocs.horse/formatting>: bold, italic, underline,
12//! strikethrough, monospace, reverse, reset, and colors by number
13//! (`0x03`) or by hex (`0x04`). Colors 0–15 address the view's palette,
14//! whose first sixteen slots are the mIRC colors, so a theme can adjust
15//! them; the extended colors 16–98 and hex colors are fixed RGB. 99 means
16//! "the default color".
17//!
18//! Nothing here fails: an incomplete code is dropped, and anything that
19//! is not a code is text.
20
21#![forbid(unsafe_code)]
22
23use rotulus_layout::{Attrs, ColorRef, ParsedText, Span, Style};
24use std::ops::Range;
25
26const BOLD: u8 = 0x02;
27const COLOR: u8 = 0x03;
28const HEX_COLOR: u8 = 0x04;
29const RESET: u8 = 0x0f;
30const MONOSPACE: u8 = 0x11;
31const REVERSE: u8 = 0x16;
32const ITALIC: u8 = 0x1d;
33const STRIKETHROUGH: u8 = 0x1e;
34const UNDERLINE: u8 = 0x1f;
35
36/// The extended colors, 16 through 98, as `0xRRGGBB`.
37const EXTENDED: [u32; 83] = [
38    0x470000, 0x472100, 0x474700, 0x324700, 0x004700, 0x00472c, 0x004747, 0x002747, 0x000047,
39    0x2e0047, 0x470047, 0x47002a, 0x740000, 0x743a00, 0x747400, 0x517400, 0x007400, 0x007449,
40    0x007474, 0x004074, 0x000074, 0x4b0074, 0x740074, 0x740045, 0xb50000, 0xb56300, 0xb5b500,
41    0x7db500, 0x00b500, 0x00b571, 0x00b5b5, 0x0063b5, 0x0000b5, 0x7500b5, 0xb500b5, 0xb5006b,
42    0xff0000, 0xff8c00, 0xffff00, 0xb2ff00, 0x00ff00, 0x00ffa0, 0x00ffff, 0x008cff, 0x0000ff,
43    0xa500ff, 0xff00ff, 0xff0098, 0xff5959, 0xffb459, 0xffff71, 0xcfff60, 0x6fff6f, 0x65ffc9,
44    0x6dffff, 0x59b4ff, 0x5959ff, 0xc459ff, 0xff66ff, 0xff59bc, 0xff9c9c, 0xffd39c, 0xffff9c,
45    0xe2ff9c, 0x9cff9c, 0x9cffdb, 0x9cffff, 0x9cd3ff, 0x9c9cff, 0xdc9cff, 0xff9cff, 0xff94d3,
46    0x000000, 0x131313, 0x282828, 0x363636, 0x4d4d4d, 0x656565, 0x818181, 0x9f9f9f, 0xbcbcbc,
47    0xe2e2e2, 0xffffff,
48];
49
50/// The sixteen standard colors as `0xRRGGBB`, for a view's default
51/// palette.
52pub const STANDARD: [u32; 16] = [
53    0xffffff, 0x000000, 0x00007f, 0x009300, 0xff0000, 0x7f0000, 0x9c009c, 0xfc7f00, 0xffff00,
54    0x00fc00, 0x009393, 0x00ffff, 0x0000fc, 0xff00ff, 0x7f7f7f, 0xd2d2d2,
55];
56
57/// The color an mIRC number names.
58pub fn color(n: u8) -> ColorRef {
59    match n {
60        0..=15 => ColorRef::Palette(n),
61        16..=98 => ColorRef::Rgb(EXTENDED[usize::from(n) - 16]),
62        _ => ColorRef::Default,
63    }
64}
65
66/// A run of the input's text, with the codes before it resolved.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Segment {
69    /// Byte range into the input; never contains a code.
70    pub range: Range<usize>,
71    pub style: Style,
72}
73
74/// Split `text` into styled segments, with the codes removed.
75///
76/// Every range lands on character boundaries: the codes are all ASCII
77/// control bytes, and the text between them is left whole.
78pub fn segments(text: &str) -> Vec<Segment> {
79    let b = text.as_bytes();
80    let mut out: Vec<Segment> = Vec::new();
81    let mut style = Style::default();
82    let mut start = 0usize;
83    let mut i = 0usize;
84
85    let flush = |from: usize, to: usize, style: Style, out: &mut Vec<Segment>| {
86        if from >= to {
87            return;
88        }
89        match out.last_mut() {
90            Some(last) if last.style == style && last.range.end == from => last.range.end = to,
91            _ => out.push(Segment {
92                range: from..to,
93                style,
94            }),
95        }
96    };
97
98    while i < b.len() {
99        let c = b[i];
100        let toggle = match c {
101            BOLD => Some(Attrs::BOLD),
102            ITALIC => Some(Attrs::ITALIC),
103            UNDERLINE => Some(Attrs::UNDERLINE),
104            STRIKETHROUGH => Some(Attrs::STRIKETHROUGH),
105            MONOSPACE => Some(Attrs::CODE),
106            REVERSE => Some(Attrs::REVERSE),
107            _ => None,
108        };
109        let is_code = toggle.is_some() || matches!(c, COLOR | HEX_COLOR | RESET);
110        if !is_code {
111            i += 1;
112            continue;
113        }
114        flush(start, i, style, &mut out);
115        i += 1;
116        if let Some(a) = toggle {
117            style.attrs = if style.attrs.contains(a) {
118                style.attrs.remove(a)
119            } else {
120                style.attrs.union(a)
121            };
122        } else if c == RESET {
123            style = Style::default();
124        } else if c == COLOR {
125            match digits(b, i) {
126                Some((fg, n)) => {
127                    i += n;
128                    style.fg = color(fg);
129                    // The comma belongs to the code only when a
130                    // background follows it; otherwise it is text.
131                    if b.get(i) == Some(&b',') {
132                        if let Some((bg, n)) = digits(b, i + 1) {
133                            i += 1 + n;
134                            style.bg = color(bg);
135                        }
136                    }
137                }
138                None => {
139                    style.fg = ColorRef::Default;
140                    style.bg = ColorRef::Default;
141                }
142            }
143        } else {
144            match hex(b, i) {
145                Some(fg) => {
146                    i += 6;
147                    style.fg = ColorRef::Rgb(fg);
148                    if b.get(i) == Some(&b',') {
149                        if let Some(bg) = hex(b, i + 1) {
150                            i += 7;
151                            style.bg = ColorRef::Rgb(bg);
152                        }
153                    }
154                }
155                None => {
156                    style.fg = ColorRef::Default;
157                    style.bg = ColorRef::Default;
158                }
159            }
160        }
161        start = i;
162    }
163    flush(start, b.len(), style, &mut out);
164    out
165}
166
167/// One or two decimal digits at `i`, and how many there were.
168fn digits(b: &[u8], i: usize) -> Option<(u8, usize)> {
169    let d0 = *b.get(i).filter(|c| c.is_ascii_digit())?;
170    match b.get(i + 1).filter(|c| c.is_ascii_digit()) {
171        Some(d1) => Some(((d0 - b'0') * 10 + (d1 - b'0'), 2)),
172        None => Some((d0 - b'0', 1)),
173    }
174}
175
176/// Six hex digits at `i`, as `0xRRGGBB`.
177fn hex(b: &[u8], i: usize) -> Option<u32> {
178    let s = b.get(i..i + 6)?;
179    if !s.iter().all(u8::is_ascii_hexdigit) {
180        return None;
181    }
182    u32::from_str_radix(std::str::from_utf8(s).ok()?, 16).ok()
183}
184
185/// `text` as styled text, with the codes removed.
186pub fn parse(text: &str) -> ParsedText {
187    let mut out = ParsedText::default();
188    for seg in segments(text) {
189        let start = out.text.len();
190        out.text.push_str(&text[seg.range]);
191        if seg.style != Style::default() {
192            out.spans.push(Span {
193                range: start..out.text.len(),
194                style: seg.style,
195            });
196        }
197    }
198    out
199}
200
201/// `text` with every formatting code removed and nothing else changed:
202/// what to put on a clipboard, or in a notification.
203pub fn strip(text: &str) -> String {
204    segments(text).into_iter().map(|s| &text[s.range]).collect()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn styled(text: &str) -> Vec<(&str, Style)> {
212        segments(text)
213            .into_iter()
214            .map(|s| (&text[s.range], s.style))
215            .collect()
216    }
217
218    fn with(attrs: Attrs) -> Style {
219        Style::default().with_attrs(attrs)
220    }
221
222    #[test]
223    fn plain_text_is_one_default_segment() {
224        assert_eq!(styled("hello there"), [("hello there", Style::default())]);
225    }
226
227    #[test]
228    fn toggles_turn_on_and_off() {
229        assert_eq!(
230            styled("a\x02b\x02c"),
231            [
232                ("a", Style::default()),
233                ("b", with(Attrs::BOLD)),
234                ("c", Style::default())
235            ]
236        );
237        assert_eq!(
238            styled("\x1ditalic\x1d \x1funder\x1f \x1estrike\x1e \x11mono"),
239            [
240                ("italic", with(Attrs::ITALIC)),
241                (" ", Style::default()),
242                ("under", with(Attrs::UNDERLINE)),
243                (" ", Style::default()),
244                ("strike", with(Attrs::STRIKETHROUGH)),
245                (" ", Style::default()),
246                ("mono", with(Attrs::CODE)),
247            ]
248        );
249    }
250
251    #[test]
252    fn toggles_nest() {
253        assert_eq!(
254            styled("\x02b\x1dbi\x02i"),
255            [
256                ("b", with(Attrs::BOLD)),
257                ("bi", with(Attrs::BOLD.union(Attrs::ITALIC))),
258                ("i", with(Attrs::ITALIC)),
259            ]
260        );
261    }
262
263    #[test]
264    fn colors_by_number() {
265        let s = styled("\x034red\x03 plain");
266        assert_eq!(
267            s[0],
268            ("red", Style::default().with_fg(ColorRef::Palette(4)))
269        );
270        assert_eq!(s[1], (" plain", Style::default()));
271
272        let s = styled("\x0304,12on blue");
273        assert_eq!(s[0].0, "on blue");
274        assert_eq!(s[0].1.fg, ColorRef::Palette(4));
275        assert_eq!(s[0].1.bg, ColorRef::Palette(12));
276
277        assert_eq!(
278            styled("\x0352x")[0].1.fg,
279            ColorRef::Rgb(0xff0000),
280            "extended colors are RGB"
281        );
282        assert_eq!(
283            styled("\x0399x")[0].1.fg,
284            ColorRef::Default,
285            "99 is the default"
286        );
287    }
288
289    #[test]
290    fn a_comma_without_a_background_is_text() {
291        let s = styled("\x034,hello");
292        assert_eq!(
293            s[0],
294            (",hello", Style::default().with_fg(ColorRef::Palette(4)))
295        );
296    }
297
298    #[test]
299    fn a_third_digit_is_text() {
300        let s = styled("\x03123");
301        assert_eq!(s[0], ("3", Style::default().with_fg(ColorRef::Palette(12))));
302    }
303
304    #[test]
305    fn a_bare_color_code_resets_colors_but_not_attributes() {
306        let s = styled("\x02\x034,5x\x03y");
307        assert_eq!(s[1].0, "y");
308        assert_eq!(s[1].1, with(Attrs::BOLD));
309    }
310
311    #[test]
312    fn hex_colors() {
313        let s = styled("\x04ff8800,000000hi");
314        assert_eq!(s[0].0, "hi");
315        assert_eq!(s[0].1.fg, ColorRef::Rgb(0xff8800));
316        assert_eq!(s[0].1.bg, ColorRef::Rgb(0x000000));
317        assert_eq!(
318            styled("\x04zzz")[0],
319            ("zzz", Style::default()),
320            "not hex: a reset, then text"
321        );
322    }
323
324    #[test]
325    fn reset_clears_everything() {
326        let s = styled("\x02\x1d\x034x\x0fy");
327        assert_eq!(s.last().unwrap(), &("y", Style::default()));
328    }
329
330    #[test]
331    fn reverse_is_carried_for_the_renderer() {
332        assert_eq!(styled("\x16rev")[0], ("rev", with(Attrs::REVERSE)));
333    }
334
335    #[test]
336    fn multibyte_text_survives_on_boundaries() {
337        let text = "\x02ñandú\x02 → ok";
338        for seg in segments(text) {
339            assert!(text.is_char_boundary(seg.range.start) && text.is_char_boundary(seg.range.end));
340        }
341        assert_eq!(strip(text), "ñandú → ok");
342    }
343
344    #[test]
345    fn parse_joins_segments_into_styled_text() {
346        let p = parse("a \x02bold\x02 b");
347        assert_eq!(p.text, "a bold b");
348        assert_eq!(p.spans.len(), 1);
349        assert_eq!(&p.text[p.spans[0].range.clone()], "bold");
350        p.debug_assert_well_formed();
351    }
352
353    #[test]
354    fn codes_alone_produce_nothing() {
355        assert!(segments("\x02\x03\x0f").is_empty());
356        assert_eq!(parse("\x02\x02").text, "");
357    }
358}