Skip to main content

rotulus_layout/
wrap.rs

1//! Line breaking and per-message layout.
2//!
3//! Produces a [`LayoutCache`] for one message at one width: its total
4//! pixel height and the line boxes needed for hit-testing. This is where
5//! both layouts live: two columns, with the timestamp and nick gutter on
6//! the left and the body indented past it, and one, with the nick at the
7//! start of the body's first line.
8//!
9//! Measurement goes through [`TextMeasure`], one call per *run*, never
10//! per character. xtext's `find_next_wrap` (xtext.c:3685) measured
11//! character by character through a full Pango layout round trip each
12//! time; that is the single hottest thing in its append path and the
13//! reason a resize of a large buffer visibly hitches.
14
15use crate::measure::TextMeasure;
16use crate::message::{Block, Message, MessageFlags};
17use crate::span::{ParsedText, Span, Style};
18use std::ops::Range;
19
20/// Identifies the conditions a [`LayoutCache`] was computed under.
21///
22/// A cache is valid only for the generation it was built at; anything
23/// that changes rendered geometry bumps a field. Critically, bumping a
24/// generation does **not** trigger recomputation — caches are rebuilt
25/// lazily when a row is next laid out, so a resize costs O(visible).
26#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
27pub struct LayoutGeneration {
28    /// Content width in pixels.
29    pub width: u32,
30    /// Bumped on font change.
31    pub font: u32,
32    /// Bumped on theme change (palette resolution can alter nothing
33    /// geometric, but a theme may carry a font).
34    pub theme: u32,
35    /// Zoom level in per-mille, so the generation stays `Eq`-comparable.
36    /// See docs/design.md "Layout, the height index, and scroll anchoring".
37    pub zoom_permille: u32,
38}
39
40/// What a [`LineBox`] draws text from.
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
42pub enum LineSource {
43    /// The left column — the pre-rendered gutter, or the speaker's nick.
44    /// Always the message's first line box when present.
45    Gutter,
46    /// Body block `n`.
47    Block(usize),
48}
49
50/// One visual line within a message.
51#[derive(Clone, PartialEq, Eq, Debug)]
52pub struct LineBox {
53    /// Y offset from the top of the message.
54    pub y: u32,
55    pub height: u32,
56    /// Byte range within the owning source's text.
57    pub range: Range<usize>,
58    /// Where this line's text comes from.
59    pub source: LineSource,
60    /// X offset — the indent column, or 0 for full-width content.
61    pub x: u32,
62    /// Measured width of the line's text, in px.
63    ///
64    /// Recorded here rather than re-measured by the view because the
65    /// wrap pass already knows it — it is the number the break decision
66    /// was made on. Anything that wants to draw a box around laid-out
67    /// text (a fenced code block, today) gets the same extent the
68    /// wrapper used, so the box cannot disagree with the wrap.
69    pub width: u32,
70}
71
72/// What a laid-out message knows about itself.
73#[derive(Clone, PartialEq, Eq, Debug)]
74pub struct LayoutCache {
75    pub generation: LayoutGeneration,
76    pub height: u32,
77    pub lines: Vec<LineBox>,
78    /// Width the gutter/nick column actually needed, before clamping to
79    /// [`LayoutParams::max_indent`].
80    pub natural_indent: u32,
81    /// Where to paint the speaker's avatar, when there is one to paint.
82    ///
83    /// Only ever set on a *group head* — a continuation row shows
84    /// neither the nick nor the icon, which is the whole point of
85    /// grouping. The view resolves the actual image from the key at
86    /// draw time, because avatars animate and a cached frame would
87    /// freeze.
88    pub avatar: Option<AvatarBox>,
89}
90
91/// A square avatar slot in the gutter, in row-local coordinates.
92#[derive(Clone, Copy, PartialEq, Eq, Debug)]
93pub struct AvatarBox {
94    pub x: u32,
95    pub y: u32,
96    pub size: u32,
97    pub key: u64,
98}
99
100/// Geometry knobs, supplied by the view.
101#[derive(Clone, Copy, PartialEq, Eq, Debug)]
102pub struct LayoutParams {
103    /// Total content width available.
104    pub width: u32,
105    /// Two-column mode: body indented past a nick/timestamp gutter.
106    pub indent: bool,
107    /// Cap on the gutter width. Below it the gutter grows to fit the
108    /// widest nick seen, which is what `gtk_xtext_fix_indent` did.
109    pub max_indent: u32,
110    /// Current gutter width, shared across the buffer so columns line
111    /// up between rows.
112    pub indent_width: u32,
113    /// Width reserved at the left of the gutter for the timestamp
114    /// column, or 0 when timestamps are off.
115    ///
116    /// The layout engine never formats a timestamp — it can't, having no
117    /// locale or time formatting — it only reserves the space the view
118    /// says it needs, so the gutter is wide enough for stamp + nick.
119    pub stamp_width: u32,
120    /// Gap between the right edge of the gutter and the body column.
121    pub gutter_gap: u32,
122    /// Left padding inside a quote block, per nesting level.
123    pub quote_indent: u32,
124    /// Vertical padding above and below an image or code block.
125    pub block_padding: u32,
126    pub word_wrap: bool,
127    /// Edge length of the avatar slot in the gutter, or 0 for no
128    /// avatars. Driven by the Settings toggle.
129    pub avatar_size: u32,
130}
131
132impl Default for LayoutParams {
133    fn default() -> Self {
134        LayoutParams {
135            width: 640,
136            indent: true,
137            max_indent: 256,
138            indent_width: 0,
139            stamp_width: 0,
140            gutter_gap: 6,
141            quote_indent: 12,
142            block_padding: 2,
143            word_wrap: true,
144            avatar_size: 0,
145        }
146    }
147}
148
149/// Lay one message out.
150pub fn layout_message(
151    msg: &Message,
152    params: &LayoutParams,
153    generation: LayoutGeneration,
154    measure: &dyn TextMeasure,
155) -> LayoutCache {
156    let metrics = measure.metrics();
157    let line_h = metrics.line_height.max(1);
158    let mut lines = Vec::new();
159    let mut y = 0u32;
160
161    // The gutter is only as wide as this message needs; the buffer
162    // reconciles the maximum across rows and re-lays out when it grows.
163    //
164    // A pre-rendered gutter (see Message::gutter) wins over the speaker's
165    // bare nick, because it carries the styling the application chose.
166    // A grouped row is a continuation of the one above: same speaker,
167    // close in time. Its gutter is suppressed so a burst of messages
168    // reads as one block under one name instead of repeating the nick
169    // (and, once avatars land, the icon) on every line. The body keeps
170    // its indent, so the column stays straight.
171    //
172    // The flag is set by ChatBuffer, which is the only thing that can
173    // see a message's neighbours. It still contributes its *natural*
174    // gutter width below, so hiding a nick never narrows the shared
175    // column and shifts every other row sideways.
176    let grouped = msg.flags.contains(crate::message::MessageFlags::GROUPED);
177
178    // An avatar is only drawn on a group head, but *every* row with a
179    // speaker reserves its width — otherwise the shared gutter narrows
180    // the moment a run forms and every column in the buffer jumps.
181    let avatar_slot = if params.indent && params.avatar_size > 0 {
182        match &msg.speaker {
183            Some(s) if s.key != 0 => params.avatar_size + params.gutter_gap,
184            _ => 0,
185        }
186    } else {
187        0
188    };
189
190    let natural_indent = if params.indent {
191        let gutter_text = match (&msg.gutter, &msg.speaker) {
192            (Some(g), _) if !g.text.is_empty() => {
193                measure_styled(g, 0..g.text.len(), measure) + metrics.space_width
194            }
195            (_, Some(s)) => measure.run_width(&s.nick, Style::default()) + metrics.space_width * 2,
196            _ => 0,
197        };
198        // The gutter holds the timestamp *and* the nick, side by side,
199        // so it has to be wide enough for both — reserving only the nick
200        // width is what makes a stamp overlap it.
201        params.stamp_width + avatar_slot + gutter_text
202    } else {
203        0
204    };
205
206    let body_x = if params.indent {
207        params.indent_width
208    } else {
209        0
210    };
211    let body_width = params.width.saturating_sub(body_x).max(line_h);
212
213    // The gutter is one unwrapped line, always first, carrying the x it
214    // is actually drawn at.
215    //
216    // Right-aligned against the body column, the way xtext aligns its
217    // left text (`ent->indent = buf->indent - left_width - space_width`,
218    // xtext.c). Computing it *here* rather than in the view matters: the
219    // view previously derived it from `params().indent_width`, which is
220    // only ever set on a local copy inside `ensure_layout` and so read
221    // back as 0 — the gutter drew hard left while bodies moved right as
222    // the column grew, and hit-testing used the same wrong x, which is
223    // why nicks could not be selected. One source of truth removes both
224    // bugs at once.
225    // The avatar sits at the left of the gutter, just past the stamp,
226    // with the nick to its right. Group heads only.
227    let avatar = if avatar_slot > 0 && !grouped {
228        msg.speaker.as_ref().map(|s| AvatarBox {
229            x: params.stamp_width,
230            y: 0,
231            size: params.avatar_size,
232            key: s.key,
233        })
234    } else {
235        None
236    };
237
238    // Where the first body line starts, in single-column mode: past the
239    // timestamp and the gutter, which share its line instead of having a
240    // column of their own. Zero in two-column mode, where both live in
241    // the gutter column.
242    let mut lead = 0u32;
243    let gutter = msg
244        .gutter
245        .as_ref()
246        .filter(|g| !g.text.is_empty() && !grouped);
247    if let Some(g) = gutter {
248        let gw = measure_styled(g, 0..g.text.len(), measure);
249        let gx = if params.indent {
250            params
251                .indent_width
252                .saturating_sub(gw + params.gutter_gap)
253                .max(params.stamp_width)
254        } else {
255            params.stamp_width
256        };
257        lines.push(LineBox {
258            y: 0,
259            height: line_h,
260            range: 0..g.text.len(),
261            source: LineSource::Gutter,
262            x: gx,
263            width: gw,
264        });
265        if !params.indent {
266            lead = gx + gw + metrics.space_width;
267        }
268    } else if !params.indent {
269        lead = params.stamp_width;
270    }
271    // Only a text body can start beside the gutter. Anything else — a
272    // code block, a quote, an image — starts on the line below it, since
273    // it has a box or an indent of its own that the lead would break. So
274    // does a body with less than half the width left beside a long nick:
275    // one word per line down the right edge is worse than a line break.
276    //
277    // Either way the first line is left to the stamp and the gutter, which
278    // the view draws there; a lead is only ever nonzero because one of
279    // them is.
280    if lead > 0 && (!matches!(msg.blocks.first(), Some(Block::Text(_))) || lead > body_width / 2) {
281        y = line_h;
282        lead = 0;
283    }
284
285    for (bi, block) in msg.blocks.iter().enumerate() {
286        match block {
287            Block::Text(p) => {
288                let first = if bi == 0 { lead } else { 0 };
289                let n = wrap_text(
290                    p,
291                    body_width,
292                    first,
293                    params.word_wrap,
294                    measure,
295                    |range, lw, dx| {
296                        lines.push(LineBox {
297                            y,
298                            height: line_h,
299                            range,
300                            source: LineSource::Block(bi),
301                            x: body_x + dx,
302                            width: lw,
303                        });
304                        y += line_h;
305                    },
306                );
307                // An empty block still occupies a line, so a blank
308                // message doesn't collapse to zero height and become
309                // unclickable.
310                if n == 0 {
311                    lines.push(LineBox {
312                        y,
313                        height: line_h,
314                        range: 0..0,
315                        source: LineSource::Block(bi),
316                        x: body_x + first,
317                        width: 0,
318                    });
319                    y += line_h;
320                }
321            }
322            Block::Code { text, .. } => {
323                y += params.block_padding;
324                // Code never wraps on words — breaking a token would
325                // change what it says. Overflow clips.
326                let mut start = 0usize;
327                // Code blocks carry no spans, so they draw — and so must
328                // measure — in the default style.
329                let plain = Style::default();
330                for (i, _) in text.match_indices('\n') {
331                    lines.push(LineBox {
332                        y,
333                        height: line_h,
334                        range: start..i,
335                        source: LineSource::Block(bi),
336                        x: body_x,
337                        width: measure.run_width(&text[start..i], plain),
338                    });
339                    y += line_h;
340                    start = i + 1;
341                }
342                lines.push(LineBox {
343                    y,
344                    height: line_h,
345                    range: start..text.len(),
346                    source: LineSource::Block(bi),
347                    x: body_x,
348                    width: measure.run_width(&text[start..], plain),
349                });
350                y += line_h + params.block_padding;
351            }
352            Block::Quote { content, depth } => {
353                let qx = body_x + params.quote_indent * u32::from(*depth).max(1);
354                let qw = params.width.saturating_sub(qx).max(line_h);
355                let n = wrap_text(content, qw, 0, params.word_wrap, measure, |range, lw, _| {
356                    lines.push(LineBox {
357                        y,
358                        height: line_h,
359                        range,
360                        source: LineSource::Block(bi),
361                        x: qx,
362                        width: lw,
363                    });
364                    y += line_h;
365                });
366                // An empty quote still gets a line box, for the same
367                // reason an empty text block does: a row with no line
368                // boxes occupies vertical space that nothing can hit-test
369                // or select, which reads as a dead patch in the buffer.
370                if n == 0 {
371                    lines.push(LineBox {
372                        y,
373                        height: line_h,
374                        range: 0..0,
375                        source: LineSource::Block(bi),
376                        x: qx,
377                        width: 0,
378                    });
379                    y += line_h;
380                }
381            }
382            Block::Image { size, alt, .. } => match size {
383                // The decode has landed: a real pixel height, not
384                // `ceil(h / fontsize)` blank text rows with a texture
385                // painted across them (xtext.c:4345). Selection,
386                // hit-testing and the marker line all just work.
387                Some(sz) => {
388                    y += params.block_padding;
389                    let (iw, h) = measure.image_size((sz.width, sz.height), body_width);
390                    lines.push(LineBox {
391                        y,
392                        height: h,
393                        range: 0..alt.len(),
394                        source: LineSource::Block(bi),
395                        x: body_x,
396                        width: iw,
397                    });
398                    y += h + params.block_padding;
399                }
400                // Not decoded yet — measure as the placeholder text, so
401                // the row doesn't jump when the bytes arrive any more
402                // than it has to.
403                None => {
404                    let p = ParsedText::plain(alt.clone());
405                    let n = wrap_text(
406                        &p,
407                        body_width,
408                        0,
409                        params.word_wrap,
410                        measure,
411                        |range, lw, _| {
412                            lines.push(LineBox {
413                                y,
414                                height: line_h,
415                                range,
416                                source: LineSource::Block(bi),
417                                x: body_x,
418                                width: lw,
419                            });
420                            y += line_h;
421                        },
422                    );
423                    // Same rule: an image whose alt text is empty must
424                    // still be clickable — that is how the user opens
425                    // the media dialog before the decode lands.
426                    if n == 0 {
427                        lines.push(LineBox {
428                            y,
429                            height: line_h,
430                            range: 0..0,
431                            source: LineSource::Block(bi),
432                            x: body_x,
433                            width: 0,
434                        });
435                        y += line_h;
436                    }
437                }
438            },
439        }
440    }
441
442    if lines.is_empty() {
443        y = line_h;
444    }
445
446    // Centre the text against the icon when the icon is the taller of
447    // the two.
448    //
449    // Without this the text sits on the icon's top edge, which reads as
450    // misalignment rather than as a design: the eye pairs a 32px icon
451    // with the line beside it, and "beside" means centred. Shifting
452    // every line box (rather than only the first) keeps a two-line
453    // message centred as a block, which is what looks right when the
454    // message is still shorter than the icon.
455    //
456    // Done here, on the finished line boxes, so hit-testing and painting
457    // move together — the alternative, offsetting only at draw time, is
458    // the class of bug where you can see text you cannot select.
459    let text_h = y.max(line_h);
460    let row_h = text_h.max(avatar.map(|a| a.size).unwrap_or(0));
461    if row_h > text_h {
462        let shift = (row_h - text_h) / 2;
463        for l in &mut lines {
464            l.y += shift;
465        }
466    }
467
468    LayoutCache {
469        generation,
470        // A head row must be at least as tall as its avatar, or the
471        // icon overflows into the row below. This is the case the
472        // "re-estimate on regroup" fix in ChatBuffer exists for: a head
473        // and its continuations now genuinely differ in height.
474        height: row_h,
475        lines,
476        natural_indent: natural_indent.min(params.max_indent),
477        avatar,
478    }
479}
480
481/// Break `p` into visual lines, calling `emit` per line with the line's
482/// range, its width, and how far right of the block's x it starts.
483/// Returns the count.
484///
485/// The first line starts `first_indent` in and is that much narrower;
486/// every later line has the full width. That is how a single-column row
487/// puts its body beside the nick rather than under it.
488fn wrap_text(
489    p: &ParsedText,
490    width: u32,
491    first_indent: u32,
492    word_wrap: bool,
493    measure: &dyn TextMeasure,
494    mut emit: impl FnMut(Range<usize>, u32, u32),
495) -> usize {
496    if p.text.is_empty() {
497        return 0;
498    }
499    let mut count = 0usize;
500    let mut line_start = 0usize;
501    let first_width = width.saturating_sub(first_indent).max(1);
502    let avail = |count: usize| if count == 0 { first_width } else { width };
503    let dx = |count: usize| if count == 0 { first_indent } else { 0 };
504
505    while line_start < p.text.len() {
506        // A hard newline always breaks.
507        let hard = p.text[line_start..]
508            .find('\n')
509            .map(|i| line_start + i)
510            .unwrap_or(p.text.len());
511        let segment = &p.text[line_start..hard];
512
513        if segment.is_empty() {
514            emit(line_start..line_start, 0, dx(count));
515            count += 1;
516            line_start = hard + 1;
517            continue;
518        }
519
520        let mut seg_start = line_start;
521        loop {
522            let rest = &p.text[seg_start..hard];
523            if rest.is_empty() {
524                break;
525            }
526            let w = measure_styled(p, seg_start..hard, measure);
527            let width = avail(count);
528            if w <= width {
529                emit(seg_start..hard, w, dx(count));
530                count += 1;
531                break;
532            }
533            // Doesn't fit: find the break point, honouring every style
534            // change between here and the boundary.
535            let (fit_abs, fit_w) = fit_styled_prefix(p, seg_start..hard, width, measure);
536            let mut brk = fit_abs.max(seg_start + 1);
537            if word_wrap {
538                if let Some(sp) = p.text[seg_start..brk].rfind([' ', '\t']) {
539                    if sp > 0 {
540                        brk = seg_start + sp;
541                    }
542                }
543            }
544            while brk < p.text.len() && !p.text.is_char_boundary(brk) {
545                brk += 1;
546            }
547            if brk <= seg_start {
548                brk = next_boundary(&p.text, seg_start);
549            }
550            // Reuse the prefix measurement when word-wrap didn't move
551            // the break; re-measure only the shorter line we actually
552            // emit. The common case costs nothing extra.
553            let lw = if brk == fit_abs {
554                fit_w
555            } else {
556                measure_styled(p, seg_start..brk, measure)
557            };
558            emit(seg_start..brk, lw, dx(count));
559            count += 1;
560            // Swallow the space we broke on.
561            seg_start = brk;
562            while seg_start < hard && p.text.as_bytes()[seg_start] == b' ' {
563                seg_start += 1;
564            }
565        }
566        line_start = hard + 1;
567    }
568    count
569}
570
571/// Walk `range` as maximal runs of uniform style, without allocating.
572///
573/// Gaps between spans are default-styled. This is the one place that
574/// knows how to walk a `ParsedText`'s style structure, so measuring and
575/// break-point search can't drift apart.
576///
577/// Callback rather than a returned `Vec` because both callers sit on the
578/// wrapping hot path — `measure_styled` runs per candidate line and
579/// `fit_styled_prefix` per overflowing line, so an allocation each would
580/// be a per-line malloc during layout. `f` returning `Some` stops the
581/// walk early, which is what lets the prefix search bail at the run that
582/// straddles the width boundary.
583fn walk_style_runs<R>(
584    p: &ParsedText,
585    range: Range<usize>,
586    mut f: impl FnMut(Range<usize>, Style) -> Option<R>,
587) -> Option<R> {
588    let mut cursor = range.start;
589    for s in &p.spans {
590        if s.range.end <= cursor || s.range.start >= range.end {
591            continue;
592        }
593        if s.range.start > cursor {
594            if let Some(r) = f(cursor..s.range.start, Style::default()) {
595                return Some(r);
596            }
597            cursor = s.range.start;
598        }
599        let end = s.range.end.min(range.end);
600        if end > cursor {
601            if let Some(r) = f(cursor..end, s.style) {
602                return Some(r);
603            }
604            cursor = end;
605        }
606    }
607    if cursor < range.end {
608        return f(cursor..range.end, Style::default());
609    }
610    None
611}
612
613/// Total width of a byte range, measured one style run at a time.
614///
615/// The per-run granularity is the point: a 200-character message with
616/// two bold words costs three measure calls, not two hundred.
617fn measure_styled(p: &ParsedText, range: Range<usize>, measure: &dyn TextMeasure) -> u32 {
618    let mut total = 0u32;
619    walk_style_runs::<()>(p, range, |r, style| {
620        total += measure.run_width(&p.text[r], style);
621        None
622    });
623    total
624}
625
626/// Largest prefix of `range` fitting in `max_width`, honouring every
627/// style change inside it.
628///
629/// The naive version — measure the whole rest with the style in effect
630/// at its *start* — is wrong as soon as a run changes style partway
631/// through, which `**bold**` and `` `code` `` both do. Bold and
632/// monospace are wider than the base font, so a break point chosen from
633/// the start style under-measures and the drawn line overflows its box.
634/// Walking runs and only calling the measurer's own prefix search on the
635/// single run that straddles the boundary is both correct and no more
636/// expensive.
637fn fit_styled_prefix(
638    p: &ParsedText,
639    range: Range<usize>,
640    max_width: u32,
641    measure: &dyn TextMeasure,
642) -> (usize, u32) {
643    let mut total = 0u32;
644    let mut cursor = range.start;
645    let straddled = walk_style_runs(p, range, |r, style| {
646        let seg = &p.text[r.clone()];
647        let w = measure.run_width(seg, style);
648        if total + w <= max_width {
649            total += w;
650            cursor = r.end;
651            return None;
652        }
653        // This run crosses the boundary — only it needs the measurer's
654        // own prefix search.
655        let (fit, fw) = measure.fit_prefix(seg, style, max_width.saturating_sub(total));
656        Some((r.start + fit, total + fw))
657    });
658    straddled.unwrap_or((cursor, total))
659}
660
661fn next_boundary(s: &str, from: usize) -> usize {
662    let mut i = from + 1;
663    while i < s.len() && !s.is_char_boundary(i) {
664        i += 1;
665    }
666    i.min(s.len())
667}
668
669/// Rough height for a row that has never been laid out.
670///
671/// Feeds the estimated heights in [`crate::index`]. Deliberately crude —
672/// it only has to keep the scrollbar plausible until the row is actually
673/// measured, and being fast matters more than being close, since it runs
674/// for every row in the buffer on a resize.
675///
676/// Crude, but not *low*. The distinction matters because while the view
677/// is following the bottom its scroll offset is `total - viewport`, and
678/// the total is this estimate for every row that has not been laid out
679/// yet — which always includes the row that was just appended. An
680/// under-estimate therefore puts "the bottom" above the real bottom and
681/// clips the newest message off the bottom edge. Over-estimating costs a
682/// slightly wrong scrollbar thumb until the row is measured, which is
683/// the tradeoff [`crate::index`] already documents.
684///
685/// Three things it has to get right for that, all of which it once got
686/// wrong: a body's *hard newlines* (dividing the byte length by the
687/// column count answers "how far would this wrap", not "how many lines
688/// does it have", so a five-line message estimated as one); the width
689/// the body actually wraps against, which in indent mode is the content
690/// width less the gutter; and the vertical padding a code or image block
691/// adds. The avatar floor is the same rule `layout_message` applies at
692/// the end.
693pub fn estimate_height(msg: &Message, params: &LayoutParams, measure: &dyn TextMeasure) -> u32 {
694    let metrics = measure.metrics();
695    let line_h = metrics.line_height.max(1);
696    let body_x = if params.indent {
697        params.indent_width
698    } else {
699        0
700    };
701    let body_width = params.width.saturating_sub(body_x).max(line_h);
702    let cols = (body_width / metrics.space_width.max(1)).max(1) as usize;
703    // Hard newlines break unconditionally; each resulting segment then
704    // wraps on its own.
705    let wrapped = |s: &str| {
706        s.split('\n')
707            .map(|seg| seg.len().div_ceil(cols).max(1))
708            .sum::<usize>()
709            .max(1)
710    };
711    let mut lines = 0usize;
712    let mut extra = 0u32;
713
714    // Single-column mode: the stamp and the gutter share the first body
715    // line, so that line has that many fewer columns. Counted in bytes,
716    // like everything else here, which over-counts a multibyte nick — on
717    // the safe side.
718    let gutter_cols = msg
719        .gutter
720        .as_ref()
721        .filter(|g| !g.text.is_empty() && !msg.flags.contains(MessageFlags::GROUPED))
722        .map(|g| g.text.len() + 1);
723    let mut lead_cols = if params.indent {
724        0
725    } else {
726        (params.stamp_width / metrics.space_width.max(1)) as usize + gutter_cols.unwrap_or(0)
727    };
728    // The same rule `layout_message` applies: a body that cannot start
729    // beside the gutter starts on the line below it.
730    let beside = matches!(msg.blocks.first(), Some(Block::Text(_))) && lead_cols <= cols / 2;
731    if lead_cols > 0 && !beside {
732        lines += 1;
733        lead_cols = 0;
734    }
735
736    for (bi, b) in msg.blocks.iter().enumerate() {
737        match b {
738            Block::Text(p) if bi == 0 && lead_cols > 0 => {
739                // Pad the first segment by the lead, then wrap as usual.
740                let (first, rest) = p.text.split_once('\n').unwrap_or((&p.text, ""));
741                lines += (first.len() + lead_cols).div_ceil(cols).max(1);
742                if p.text.contains('\n') {
743                    lines += wrapped(rest);
744                }
745            }
746            Block::Text(p) => lines += wrapped(&p.text),
747            Block::Code { text, .. } => {
748                // Code never wraps, so it is exactly its own line count —
749                // but it is boxed, and the box has padding above and
750                // below.
751                lines += text.split('\n').count().max(1);
752                extra += params.block_padding * 2;
753            }
754            Block::Quote { content, depth } => {
755                let qx = body_x + params.quote_indent * u32::from(*depth).max(1);
756                let qw = params.width.saturating_sub(qx).max(line_h);
757                let qcols = (qw / metrics.space_width.max(1)).max(1) as usize;
758                lines += content
759                    .text
760                    .split('\n')
761                    .map(|seg| seg.len().div_ceil(qcols).max(1))
762                    .sum::<usize>()
763                    .max(1);
764            }
765            Block::Image { size, alt, .. } => match size {
766                Some(sz) => {
767                    let (_, h) = measure.image_size((sz.width, sz.height), body_width);
768                    extra += h + params.block_padding * 2;
769                }
770                None => lines += wrapped(alt),
771            },
772        }
773    }
774    let text_h = (lines.max(1) as u32) * line_h + extra;
775    // A group head is at least as tall as the icon it draws.
776    let avatar_h = if params.indent
777        && params.avatar_size > 0
778        && !msg.flags.contains(MessageFlags::GROUPED)
779        && msg.speaker.as_ref().is_some_and(|s| s.key != 0)
780    {
781        params.avatar_size
782    } else {
783        0
784    };
785    text_h.max(avatar_h)
786}
787
788/// Whether a message draws in the muted secondary colour.
789pub fn is_muted(msg: &Message) -> bool {
790    msg.flags.contains(MessageFlags::MUTED) || msg.flags.contains(MessageFlags::DELETED)
791}
792
793/// Spans of `p` clipped to `range`, for the view's snapshot pass.
794pub fn spans_in(p: &ParsedText, range: Range<usize>) -> Vec<Span> {
795    p.spans
796        .iter()
797        .filter(|s| s.range.start < range.end && s.range.end > range.start)
798        .map(|s| Span {
799            range: s.range.start.max(range.start)..s.range.end.min(range.end),
800            style: s.style,
801        })
802        .collect()
803}