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}