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}