Skip to main content

rotulus_layout/
message.rs

1//! The structured message model.
2//!
3//! This is the thing xtext never had. There, a chat line was a byte
4//! string with in-band escapes; a speaker, a message id, an avatar or a hit
5//! region had nowhere to live, which is why late features had to be
6//! smuggled in as a magic word (`hxmedia:N`) or a magic
7//! non-breaking-space sentinel. Here a message is a value with fields.
8//!
9//! See docs/design.md "The message model".
10
11use crate::span::ParsedText;
12
13/// Stable identity for one row, unique within a [`crate::ChatBuffer`].
14///
15/// Allocated by the buffer, never reused within a session. Marks handed
16/// out to callers are `MessageId`s, so a row that gets trimmed or cleared
17/// leaves the caller holding an id that simply no longer resolves — the
18/// weak-reference semantics `rotulus.h` already documents, but without
19/// the dangling-pointer hazard xtext's raw `textentry *` cursors had.
20#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
21pub struct MessageId(pub u64);
22
23/// Which direction a [`MessageKind::LoadMore`] row pages in.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum LoadMoreDirection {
26    Older,
27    Newer,
28}
29
30/// What a row *is*.
31///
32/// Note `LoadMore` and `Divider`: under xtext both were ordinary text
33/// rows whose meaning was recovered by string-matching the rendered
34/// bytes — `chat_history_word_click` compared the clicked word against a
35/// composed "↑\u{a0}Load\u{a0}older\u{a0}messages" sentinel, non-breaking
36/// spaces and all, because xtext's tokenizer splits on ASCII space.
37/// Making them row kinds retires that.
38#[derive(Clone, PartialEq, Eq, Debug)]
39pub enum MessageKind {
40    /// A live message from the server.
41    Live,
42    /// A backfilled message, carrying the server's message id so the
43    /// paging cursor can be derived from the buffer rather than tracked
44    /// alongside it.
45    History { server_message_id: u64 },
46    /// A rule with a caption ("chat history (12 messages)").
47    Divider,
48    /// The clickable paging row.
49    LoadMore(LoadMoreDirection),
50    /// Client-generated notice — connection state, task errors, /me
51    /// output, the old `INFOPREFIX` lines.
52    System,
53}
54
55impl MessageKind {
56    /// Part of a chat-history block: backfilled messages and the rows that
57    /// frame them. These don't count against the scrollback cap — the user
58    /// asked for them — and are never trimmed to make room for live rows.
59    pub fn is_history(&self) -> bool {
60        matches!(
61            self,
62            MessageKind::History { .. } | MessageKind::Divider | MessageKind::LoadMore(_)
63        )
64    }
65}
66
67/// Who said it.
68#[derive(Clone, PartialEq, Eq, Debug)]
69pub struct Speaker {
70    /// The application's identity for this person, or 0 when it has
71    /// none.
72    ///
73    /// Opaque to the view: it is compared for grouping, handed back in
74    /// `speaker-menu`, and passed to the avatar resolver, and nothing
75    /// else. A Hotline client uses its 16-bit user id; an IRC client
76    /// might hash the account name. 0 is "unknown" rather than "user
77    /// zero", and a row with an unknown speaker gets no avatar slot,
78    /// since there would be nothing to resolve it against.
79    pub key: u64,
80    pub nick: String,
81    /// A per-person color, `0x00RRGGBB`. `None` means "use the view's
82    /// default nick color".
83    pub color: Option<u32>,
84}
85
86impl Message {
87    /// The key two messages must share to be grouped, or `None` for a
88    /// message that never groups (system rows, dividers, `/me`).
89    ///
90    /// Both halves matter, and each catches a case the other misses:
91    ///
92    /// - The **key** separates two people who happen to share a nick.
93    /// - The **rendered nick** separates one person before and after a
94    ///   rename. The key survives a rename, so keying on it alone would
95    ///   group the messages and the new name would simply never appear —
96    ///   which is worse than repeating it, since the change is exactly
97    ///   what the reader needs to see.
98    ///
99    /// The nick compared is the *gutter text as drawn*, not
100    /// `Speaker.nick`, because what a reader notices is the label on
101    /// screen changing.
102    pub fn group_key(&self) -> Option<GroupKey<'_>> {
103        if self.flags.contains(MessageFlags::ACTION) || self.flags.contains(MessageFlags::DELETED) {
104            return None;
105        }
106        // Client-generated notices never group. They share a gutter
107        // ("[hx]") without sharing a speaker, so keying on the drawn
108        // nick would collapse a run of unrelated status lines —
109        // "connecting", "connected", "login ok" — into one block under
110        // a single tag, which reads as one event rather than three.
111        //
112        // Checked on the kind rather than on `speaker.is_none()`: some
113        // sources carry no identity at all (a pre-1.5 Hotline server
114        // sends chat with no uid), and those rows are real messages from
115        // a real person that should still group by nick.
116        if self.kind == MessageKind::System {
117            return None;
118        }
119        let key = self.speaker.as_ref().map(|s| s.key).unwrap_or(0);
120        // A row with no gutter at all is a system line and never groups.
121        let nick = match &self.gutter {
122            Some(g) if !g.text.is_empty() => g.text.as_str(),
123            _ => return None,
124        };
125        Some(GroupKey { key, nick })
126    }
127}
128
129/// What makes two adjacent messages "the same speaker, still".
130///
131/// Equality is on both fields: same person *and* same displayed name.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct GroupKey<'a> {
134    /// 0 when the speaker's identity is unknown — then the nick carries
135    /// the whole decision, which is the best available answer.
136    pub key: u64,
137    /// The gutter text as rendered.
138    pub nick: &'a str,
139}
140
141impl Speaker {
142    pub fn new(key: u64, nick: impl Into<String>) -> Speaker {
143        Speaker {
144            key,
145            nick: nick.into(),
146            color: None,
147        }
148    }
149}
150
151/// Intrinsic pixel size of an image block, as reported by the decoder.
152#[derive(Clone, Copy, PartialEq, Eq, Debug)]
153pub struct ImageSize {
154    pub width: u32,
155    pub height: u32,
156}
157
158/// One piece of a message's body.
159///
160/// Adding a kind of content is adding a variant here plus a measure arm
161/// and a snapshot arm in the view — as opposed to xtext, where inline
162/// media needed a discriminator on `textentry`, a side-allocated
163/// `xtext_media_data`, a parallel render path, and a padding hack in the
164/// line-count math.
165#[derive(Clone, PartialEq, Eq, Debug)]
166pub enum Block {
167    Text(ParsedText),
168    /// A fenced markdown code block. Rendered monospace in a tinted
169    /// panel, never wrapped mid-token, never parsed for other markup.
170    Code {
171        text: String,
172        language: Option<String>,
173    },
174    /// A markdown `>` quote. `depth` counts nesting.
175    Quote {
176        content: ParsedText,
177        depth: u8,
178    },
179    /// Server-validated inline media. `texture` is deliberately absent
180    /// from this crate — the layout engine only needs the size, and a
181    /// `GdkTexture` cannot cross into a GTK-free crate. The view keys its
182    /// own texture table off `token`.
183    Image {
184        token: u32,
185        /// `None` until the decode lands; the block measures as its
186        /// `alt` text until then, exactly as the placeholder row does
187        /// today.
188        size: Option<ImageSize>,
189        alt: String,
190    },
191}
192
193impl Block {
194    pub fn text(t: impl Into<String>) -> Block {
195        Block::Text(ParsedText::plain(t))
196    }
197}
198
199/// A message's body.
200///
201/// Nearly every message is one text block, which is held inline; a body
202/// markdown split into paragraphs, code and quotes holds a vector. A plain
203/// `Vec` for every row would cost a separate allocation (and its header)
204/// for a single block, which is most of a short message's body. Reads go
205/// through the slice either way.
206#[derive(Clone, PartialEq, Eq, Debug)]
207pub enum Blocks {
208    One(Block),
209    Many(Vec<Block>),
210}
211
212impl Blocks {
213    pub fn new(mut v: Vec<Block>) -> Blocks {
214        if v.len() == 1 {
215            Blocks::One(v.pop().expect("one block"))
216        } else {
217            Blocks::Many(v)
218        }
219    }
220}
221
222impl std::ops::Deref for Blocks {
223    type Target = [Block];
224
225    fn deref(&self) -> &[Block] {
226        match self {
227            Blocks::One(b) => std::slice::from_ref(b),
228            Blocks::Many(v) => v,
229        }
230    }
231}
232
233impl std::ops::DerefMut for Blocks {
234    fn deref_mut(&mut self) -> &mut [Block] {
235        match self {
236            Blocks::One(b) => std::slice::from_mut(b),
237            Blocks::Many(v) => v,
238        }
239    }
240}
241
242impl From<Vec<Block>> for Blocks {
243    fn from(v: Vec<Block>) -> Blocks {
244        Blocks::new(v)
245    }
246}
247
248impl From<Block> for Blocks {
249    fn from(b: Block) -> Blocks {
250        Blocks::One(b)
251    }
252}
253
254impl<'a> IntoIterator for &'a Blocks {
255    type Item = &'a Block;
256    type IntoIter = std::slice::Iter<'a, Block>;
257
258    fn into_iter(self) -> Self::IntoIter {
259        self.iter()
260    }
261}
262
263impl<'a> IntoIterator for &'a mut Blocks {
264    type Item = &'a mut Block;
265    type IntoIter = std::slice::IterMut<'a, Block>;
266
267    fn into_iter(self) -> Self::IntoIter {
268        self.iter_mut()
269    }
270}
271
272/// Per-message rendering flags.
273#[derive(Clone, Copy, PartialEq, Eq, Default, Hash)]
274pub struct MessageFlags(pub u8);
275
276impl MessageFlags {
277    pub const NONE: MessageFlags = MessageFlags(0);
278    /// Matched the highlight word list — the whole row draws emphasised.
279    pub const HIGHLIGHT: MessageFlags = MessageFlags(1 << 0);
280    /// Backfilled history: drawn in the muted secondary colour.
281    pub const MUTED: MessageFlags = MessageFlags(1 << 1);
282    /// A `/me` action: "* nick does something", no nick column.
283    pub const ACTION: MessageFlags = MessageFlags(1 << 2);
284    /// Originated here rather than arriving from the server.
285    ///
286    /// Direction, not sender identity — "is the sender me" cannot tell
287    /// the echo of a message you just sent from the server's copy of it
288    /// when you message yourself, and grouping needs to. See
289    /// `rotulus.h`'s `ROTULUS_ROW_OUTGOING`.
290    pub const OUTGOING: MessageFlags = MessageFlags(1 << 3);
291    /// Server tombstone for a deleted message.
292    pub const DELETED: MessageFlags = MessageFlags(1 << 4);
293    /// A continuation of the row above: same speaker, close in time, so
294    /// the gutter is suppressed and only the body draws. Set by
295    /// [`ChatBuffer`](crate::ChatBuffer), never by the caller — it is a
296    /// property of a message's *neighbours*, not of the message, and
297    /// gets recomputed when they change.
298    pub const GROUPED: MessageFlags = MessageFlags(1 << 5);
299
300    #[inline]
301    pub fn contains(self, other: MessageFlags) -> bool {
302        self.0 & other.0 == other.0
303    }
304
305    #[inline]
306    pub fn union(self, other: MessageFlags) -> MessageFlags {
307        MessageFlags(self.0 | other.0)
308    }
309}
310
311impl std::fmt::Debug for MessageFlags {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        if self.0 == 0 {
314            return f.write_str("NONE");
315        }
316        let mut first = true;
317        for (bit, name) in [
318            (MessageFlags::HIGHLIGHT, "HIGHLIGHT"),
319            (MessageFlags::MUTED, "MUTED"),
320            (MessageFlags::ACTION, "ACTION"),
321            (MessageFlags::OUTGOING, "OUTGOING"),
322            (MessageFlags::DELETED, "DELETED"),
323        ] {
324            if self.contains(bit) {
325                if !first {
326                    f.write_str("|")?;
327                }
328                f.write_str(name)?;
329                first = false;
330            }
331        }
332        Ok(())
333    }
334}
335
336/// A row.
337#[derive(Clone, PartialEq, Eq, Debug)]
338pub struct Message {
339    pub kind: MessageKind,
340    /// Unix seconds. The C API turns a `stamp` of 0 into "now".
341    pub timestamp: i64,
342    pub speaker: Option<Speaker>,
343    /// The nick column as the application styled it — brackets in one
344    /// color, the name in another, a status tag — overriding the bare
345    /// [`Self::speaker`] nick, which only sizes the column when this is
346    /// absent.
347    pub gutter: Option<ParsedText>,
348    pub blocks: Blocks,
349    pub flags: MessageFlags,
350}
351
352impl Message {
353    /// A live message with a single text body.
354    pub fn live(speaker: Speaker, body: ParsedText) -> Message {
355        Message {
356            kind: MessageKind::Live,
357            timestamp: 0,
358            speaker: Some(speaker),
359            gutter: None,
360            blocks: Blocks::One(Block::Text(body)),
361            flags: MessageFlags::NONE,
362        }
363    }
364
365    /// A client-generated notice with no speaker.
366    pub fn system(body: ParsedText) -> Message {
367        Message {
368            kind: MessageKind::System,
369            timestamp: 0,
370            speaker: None,
371            gutter: None,
372            blocks: Blocks::One(Block::Text(body)),
373            flags: MessageFlags::NONE,
374        }
375    }
376
377    /// Give back the spare capacity building the message left behind. See
378    /// [`ParsedText::compact`].
379    pub fn compact(&mut self) {
380        use crate::span::{exact_string, exact_vec};
381        if let Some(s) = &mut self.speaker {
382            exact_string(&mut s.nick);
383        }
384        if let Some(g) = &mut self.gutter {
385            g.compact();
386        }
387        if let Blocks::Many(v) = &mut self.blocks {
388            exact_vec(v);
389        }
390        for b in self.blocks.iter_mut() {
391            match b {
392                Block::Text(p) => p.compact(),
393                Block::Quote { content, .. } => content.compact(),
394                Block::Code { text, language } => {
395                    exact_string(text);
396                    if let Some(l) = language {
397                        exact_string(l);
398                    }
399                }
400                Block::Image { alt, .. } => exact_string(alt),
401            }
402        }
403    }
404
405    pub fn with_flags(mut self, f: MessageFlags) -> Message {
406        self.flags = self.flags.union(f);
407        self
408    }
409
410    pub fn with_timestamp(mut self, ts: i64) -> Message {
411        self.timestamp = ts;
412        self
413    }
414
415    /// The plain text of the whole message, blocks joined by newlines.
416    /// Used for clipboard extraction and for the search index; image
417    /// blocks contribute their alt text, which is the behaviour xtext
418    /// approximated by storing the placeholder as `ent->str`.
419    pub fn to_plain_text(&self) -> String {
420        let mut out = String::new();
421        for (i, b) in self.blocks.iter().enumerate() {
422            if i > 0 {
423                out.push('\n');
424            }
425            match b {
426                Block::Text(p) => out.push_str(&p.text),
427                Block::Code { text, .. } => out.push_str(text),
428                Block::Quote { content, .. } => out.push_str(&content.text),
429                Block::Image { alt, .. } => out.push_str(alt),
430            }
431        }
432        out
433    }
434}