Skip to main content

rotulus_layout/
buffer.rs

1//! The buffer: rows, marks, lazy layout, trim.
2//!
3//! Owns the message list, the [`HeightIndex`] that shadows it, and the
4//! [`ScrollAnchor`]. This is the object the view drives.
5//!
6//! The API mirrors what `rotulus.h` exposes to C — append,
7//! insert-before-a-mark, replace, remove-a-mark, clear, trim — so the
8//! widget's FFI layer is a translation rather than a redesign. A mark is
9//! a [`MessageId`], not a pointer into a linked list, so a stale one is
10//! inert rather than dangling.
11
12use crate::anchor::{AnchorResolver, Gravity, ScrollAnchor};
13use crate::index::HeightIndex;
14use crate::measure::TextMeasure;
15use crate::message::{Block, Message, MessageFlags, MessageId};
16use crate::select::{Caret, RowSelection, Selection};
17use crate::span::Style;
18use crate::wrap::LineSource;
19use crate::wrap::{estimate_height, layout_message, LayoutCache, LayoutGeneration, LayoutParams};
20use std::collections::HashMap;
21use std::collections::VecDeque;
22
23/// Floor for a hand-dragged gutter, so it can't be collapsed to
24/// nothing and become impossible to grab again.
25pub const MIN_INDENT: u32 = 16;
26
27/// Default gap that breaks a run of messages from one speaker.
28///
29/// Five minutes. Short enough that "hai / hai / hai" collapses to one
30/// block with one nick, long enough that returning to a room after a
31/// break shows who is talking again rather than silently attaching your
32/// message to something you said an hour ago.
33pub const DEFAULT_GROUP_GAP_SECS: i64 = 300;
34
35#[derive(Debug)]
36struct Row {
37    id: MessageId,
38    msg: Message,
39    /// Boxed, because only the rows that have been drawn have one: inline,
40    /// every row in the scrollback would reserve room for a layout it will
41    /// most likely never get.
42    layout: Option<Box<LayoutCache>>,
43}
44
45/// A scrollable list of laid-out messages.
46#[derive(Debug)]
47pub struct ChatBuffer {
48    rows: VecDeque<Row>,
49    index: HeightIndex,
50    next_id: u64,
51
52    /// `MessageId` → row position.
53    ///
54    /// Rebuilt lazily: any structural change that shifts positions sets
55    /// `pos_dirty`, and the next lookup pays one O(n) rebuild and then
56    /// serves O(1) until the next change. A history batch is one
57    /// rebuild for the batch rather than one per row, which is the case
58    /// that actually matters. (xtext did a full linear scan *per*
59    /// lookup — `gtk_xtext_find_media_entry_by_token`, xtext.c:6386 —
60    /// so even the unamortised path is no worse.)
61    ///
62    /// Trimming is the exception. It only ever removes rows from the front,
63    /// which shifts every surviving position down by the same amount, so
64    /// instead of rebuilding it drops the trimmed ids and raises
65    /// `pos_base`. A stored value is the row position plus `pos_base`. At
66    /// the scrollback cap every append trims, and a rebuild there would
67    /// make each new message cost O(scrollback).
68    pos: HashMap<MessageId, usize>,
69    pos_base: usize,
70    pos_dirty: bool,
71    /// The rows most recently looked up by id while `pos` was dirty, kept
72    /// in step through inserts, removals and trims.
73    ///
74    /// A Load-older page inserts row after row above one anchor, and each
75    /// insert dirties `pos`; finding the insert anchor, and then the
76    /// reading position for the scrollbar, rebuilt the whole map for every
77    /// row of the page, which made the page quadratic in the scrollback.
78    /// Those are the only two rows asked for, so they are remembered. Each
79    /// is checked against its row before use, so a change these don't
80    /// follow just falls back to the rebuild.
81    hints: [Option<(MessageId, usize)>; 2],
82
83    params: LayoutParams,
84    generation: LayoutGeneration,
85    anchor: ScrollAnchor,
86
87    /// Scrollback cap on live rows. 0 disables trimming. History rows
88    /// (see [`MessageKind::is_history`]) don't count against it.
89    max_rows: usize,
90    /// How many rows are history, so the live count is `rows.len()` minus
91    /// this without a scan.
92    history_rows: usize,
93    /// Widest gutter any row has asked for, so columns align.
94    indent_width: u32,
95    /// How long a gap breaks a run of messages from one speaker, in
96    /// seconds. 0 disables grouping entirely.
97    group_gap_secs: i64,
98
99    /// Set once the user drags the separator, which freezes the gutter.
100    ///
101    /// xtext left auto-growth on after a drag, so a long nick could
102    /// silently undo a narrowing the user had just made by hand (and a
103    /// widening only stuck because it happened to exceed
104    /// `max_auto_indent`, which switched the auto path off as a side
105    /// effect). An explicit pin is the behaviour that was being
106    /// approximated.
107    indent_pinned: bool,
108}
109
110impl ChatBuffer {
111    pub fn new(params: LayoutParams) -> ChatBuffer {
112        let generation = LayoutGeneration {
113            width: params.width,
114            font: 0,
115            theme: 0,
116            zoom_permille: 1000,
117        };
118        ChatBuffer {
119            rows: VecDeque::new(),
120            index: HeightIndex::new(),
121            next_id: 1,
122            pos: HashMap::new(),
123            pos_base: 0,
124            pos_dirty: false,
125            hints: [None; 2],
126            params,
127            generation,
128            anchor: ScrollAnchor::bottom(),
129            max_rows: 0,
130            history_rows: 0,
131            group_gap_secs: DEFAULT_GROUP_GAP_SECS,
132            indent_width: 0,
133            indent_pinned: false,
134        }
135    }
136
137    pub fn len(&self) -> usize {
138        self.rows.len()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.rows.is_empty()
143    }
144
145    pub fn anchor(&self) -> ScrollAnchor {
146        self.anchor
147    }
148
149    pub fn set_anchor(&mut self, a: ScrollAnchor) {
150        self.anchor = a;
151    }
152
153    pub fn params(&self) -> &LayoutParams {
154        &self.params
155    }
156
157    pub fn generation(&self) -> LayoutGeneration {
158        self.generation
159    }
160
161    /// The scrollback cap on live rows; 0 is no limit.
162    pub fn max_rows(&self) -> usize {
163        self.max_rows
164    }
165
166    /// Scrollback cap on live rows. Matches `rotulus_view_set_max_lines`.
167    pub fn set_max_rows(&mut self, n: usize, measure: &dyn TextMeasure) {
168        self.max_rows = n;
169        self.trim(measure);
170    }
171
172    pub fn message(&self, id: MessageId) -> Option<&Message> {
173        self.row_of(id)
174            .and_then(|r| self.rows.get(r))
175            .map(|r| &r.msg)
176    }
177
178    pub fn message_at(&self, row: usize) -> Option<&Message> {
179        self.rows.get(row).map(|r| &r.msg)
180    }
181
182    pub fn id_at(&self, row: usize) -> Option<MessageId> {
183        self.rows.get(row).map(|r| r.id)
184    }
185
186    /// Row position of `id`, or `None` if it has been trimmed/cleared.
187    pub fn row_of(&self, id: MessageId) -> Option<usize> {
188        if self.pos_dirty {
189            // Cold path: the caller holds `&self`, so rebuild by scan
190            // rather than mutating. Callers that do this in a loop
191            // should take `&mut self` and call `reindex` first.
192            return self.rows.iter().position(|r| r.id == id);
193        }
194        self.pos.get(&id).map(|p| p - self.pos_base)
195    }
196
197    /// The layout params as the estimator should see them, with the
198    /// settled gutter width filled in. `ensure_layout` builds the same
199    /// thing; sharing it keeps an estimate and its eventual real layout
200    /// measuring the same shape.
201    fn layout_params(&self) -> LayoutParams {
202        let mut p = self.params;
203        p.indent_width = self.indent_width;
204        p
205    }
206
207    /// Rebuild the id → position map if stale.
208    pub fn reindex(&mut self) {
209        if !self.pos_dirty {
210            return;
211        }
212        self.pos.clear();
213        self.pos_base = 0;
214        for (i, r) in self.rows.iter().enumerate() {
215            self.pos.insert(r.id, i);
216        }
217        self.pos_dirty = false;
218    }
219
220    // ---- mutation --------------------------------------------------
221
222    /// Append a message; returns its mark.
223    pub fn append(&mut self, mut msg: Message, measure: &dyn TextMeasure) -> MessageId {
224        msg.compact();
225        let id = self.alloc_id();
226        if self.groups_with_previous(&msg, self.rows.len()) {
227            msg.flags = msg.flags.union(MessageFlags::GROUPED);
228        }
229        let h = estimate_height(&msg, &self.layout_params(), measure);
230        if msg.kind.is_history() {
231            self.history_rows += 1;
232        }
233        self.rows.push_back(Row {
234            id,
235            msg,
236            layout: None,
237        });
238        self.index.push_back(h, false);
239        if !self.pos_dirty {
240            self.pos.insert(id, self.pos_base + self.rows.len() - 1);
241        }
242        self.trim(measure);
243        id
244    }
245
246    /// Insert immediately before `anchor`, or at the front if `anchor`
247    /// is `None` or stale. Returns the new mark.
248    ///
249    /// This is the chat-history Load-Older path. Nothing here needs to
250    /// compensate the scroll position: the anchor names a row, that row
251    /// did not move, so the viewport does not move. xtext had to bump
252    /// `pagetop_line` / `pagetop_subline` / `last_pixel_pos` /
253    /// `old_value` by the inserted row's subline count to approximate
254    /// the same effect (xtext.c:5720).
255    pub fn insert_before(
256        &mut self,
257        anchor: Option<MessageId>,
258        mut msg: Message,
259        measure: &dyn TextMeasure,
260    ) -> MessageId {
261        msg.compact();
262        let at = match anchor {
263            Some(a) => self.locate(a).unwrap_or(0),
264            None => 0,
265        };
266        let id = self.alloc_id();
267        let h = estimate_height(&msg, &self.layout_params(), measure);
268        if msg.kind.is_history() {
269            self.history_rows += 1;
270        }
271        self.rows.insert(
272            at,
273            Row {
274                id,
275                msg,
276                layout: None,
277            },
278        );
279        self.index.insert(at, h, false);
280        self.pos_dirty = true;
281        self.shift_hints(at, 1);
282        // An insert splits whatever run spanned this point: the new row
283        // may continue the one above, and the row below may no longer
284        // continue what is now two rows up. Nothing further down moves
285        // relative to its predecessor.
286        self.regroup_rows(at..at + 2, measure);
287        id
288    }
289
290    /// Remove the row `id` names. `false` if the mark was already stale,
291    /// which is not an error — it is how a caller learns the row was
292    /// trimmed.
293    pub fn remove(&mut self, id: MessageId, measure: &dyn TextMeasure) -> bool {
294        let Some(row) = self.locate(id) else {
295            return false;
296        };
297        self.remove_row(row, measure);
298        true
299    }
300
301    /// Remove the row at `row`, which the caller has already found.
302    fn remove_row(&mut self, row: usize, measure: &dyn TextMeasure) {
303        let id = self.rows[row].id;
304        if self.rows[row].msg.kind.is_history() {
305            self.history_rows -= 1;
306        }
307        self.rows.remove(row);
308        self.index.remove(row);
309        self.pos_dirty = true;
310        self.forget_hint(row);
311        self.shift_hints(row + 1, -1);
312        // The row that moved up has a new predecessor. Removing a run's
313        // head would otherwise leave the next row suppressing a nick that
314        // nothing above it shows.
315        self.regroup_rows(row..row + 1, measure);
316        if self.anchor.message == Some(id) {
317            // Re-anchor to the row that took its place, so removing the
318            // "Load older" sentinel from under the viewport doesn't
319            // snap the view to the bottom.
320            self.anchor.message = self.rows.get(row).map(|r| r.id);
321            if self.anchor.message.is_none() {
322                self.anchor = ScrollAnchor::bottom();
323            }
324        }
325    }
326
327    /// Replace a message in place, e.g. to attach a decoded image size.
328    pub fn replace(&mut self, id: MessageId, mut msg: Message, measure: &dyn TextMeasure) -> bool {
329        msg.compact();
330        self.reindex();
331        let Some(row) = self.row_of(id) else {
332            return false;
333        };
334        let h = estimate_height(&msg, &self.layout_params(), measure);
335        let was = self.rows[row].msg.kind.is_history();
336        let now = msg.kind.is_history();
337        self.rows[row].msg = msg;
338        self.rows[row].layout = None;
339        match (was, now) {
340            (false, true) => self.history_rows += 1,
341            (true, false) => self.history_rows -= 1,
342            _ => {}
343        }
344        self.index.set_height(row, h, false);
345        if was && !now {
346            self.trim(measure);
347        }
348        true
349    }
350
351    /// Attach a decoded size to an image block, growing the row.
352    ///
353    /// The single operation the old design was worst at: xtext had to
354    /// recompute the entry's subline list, diff the count, and patch the
355    /// buffer's `num_lines` plus the scroll anchors. Here it is a height
356    /// change, and the anchor absorbs it.
357    /// Attach (or with `None`, clear) the decoded size of an image
358    /// block.
359    ///
360    /// `None` is the decode-failed path: the row must shrink back to its
361    /// placeholder height, or the alt text ends up floating inside a
362    /// tall empty box the size of an image that never arrived.
363    pub fn set_image_size(
364        &mut self,
365        id: MessageId,
366        token: u32,
367        size: Option<crate::message::ImageSize>,
368        measure: &dyn TextMeasure,
369    ) -> bool {
370        self.reindex();
371        let Some(row) = self.row_of(id) else {
372            return false;
373        };
374        let mut hit = false;
375        for b in &mut self.rows[row].msg.blocks {
376            if let Block::Image {
377                token: t, size: s, ..
378            } = b
379            {
380                if *t == token {
381                    *s = size;
382                    hit = true;
383                }
384            }
385        }
386        if !hit {
387            return false;
388        }
389        self.rows[row].layout = None;
390        let params = self.layout_params();
391        let h = estimate_height(&self.rows[row].msg, &params, measure);
392        self.index.set_height(row, h, false);
393        true
394    }
395
396    /// Find the row carrying an image block with `token`.
397    pub fn find_image(&self, token: u32) -> Option<MessageId> {
398        self.rows
399            .iter()
400            .find(|r| {
401                r.msg
402                    .blocks
403                    .iter()
404                    .any(|b| matches!(b, Block::Image { token: t, .. } if *t == token))
405            })
406            .map(|r| r.id)
407    }
408
409    pub fn clear(&mut self) {
410        self.rows.clear();
411        self.index = HeightIndex::new();
412        self.pos.clear();
413        self.pos_base = 0;
414        self.pos_dirty = false;
415        self.hints = [None; 2];
416        self.history_rows = 0;
417        self.anchor = ScrollAnchor::bottom();
418        self.reset_indent();
419    }
420
421    /// Bring the live rows back under the cap by dropping the oldest of
422    /// them.
423    ///
424    /// History rows don't count and are never dropped here: the replay on
425    /// join, a reconnect's catch-up and "Load older" pages. A page the user
426    /// asked for, inserted above a full scrollback, used to be trimmed
427    /// away, whole, by the next live message. History is bounded instead by
428    /// the server and the replay preference, and cleared on reconnect.
429    fn trim(&mut self, measure: &dyn TextMeasure) {
430        let live = self.rows.len() - self.history_rows;
431        if self.max_rows == 0 || live <= self.max_rows {
432            return;
433        }
434        let mut excess = live - self.max_rows;
435
436        // Live rows at the very front go the cheap way.
437        let front = self
438            .rows
439            .iter()
440            .take(excess)
441            .take_while(|r| !r.msg.kind.is_history())
442            .count();
443        if front > 0 {
444            for _ in 0..front {
445                if let Some(row) = self.rows.pop_front() {
446                    // A dirty map is rebuilt from `rows` on the next
447                    // lookup, so it has nothing to keep in step.
448                    if !self.pos_dirty {
449                        self.pos.remove(&row.id);
450                    }
451                }
452            }
453            self.index.drain_front(front);
454            if !self.pos_dirty {
455                self.pos_base += front;
456            }
457            for r in 0..front {
458                self.forget_hint(r);
459            }
460            self.shift_hints(front, -(front as isize));
461            // The trimmed rows may have included a run's head. Whatever
462            // is at the front now cannot be a continuation of anything;
463            // every other row keeps the predecessor it had.
464            self.regroup_rows(0..1, measure);
465            excess -= front;
466        }
467
468        // The rest are below a history block at the top: the oldest live
469        // rows after it.
470        while excess > 0 {
471            let Some(row) = self.rows.iter().position(|r| !r.msg.kind.is_history()) else {
472                break;
473            };
474            // By position: the row is in hand, and looking it up by id
475            // would rebuild the whole map for every message at the cap.
476            self.remove_row(row, measure);
477            excess -= 1;
478        }
479    }
480
481    /// Grouping controls. 0 disables it; rows already in the buffer are
482    /// re-evaluated, since the flag describes neighbours rather than the
483    /// message itself.
484    pub fn set_group_gap_secs(&mut self, secs: i64, measure: &dyn TextMeasure) {
485        if secs == self.group_gap_secs {
486            return;
487        }
488        self.group_gap_secs = secs.max(0);
489        self.regroup_rows(0..self.rows.len(), measure);
490    }
491
492    pub fn group_gap_secs(&self) -> i64 {
493        self.group_gap_secs
494    }
495
496    /// Would `msg` continue the run ending at `before` (an index into
497    /// `rows`, so `rows.len()` means "appending at the end")?
498    fn groups_with_previous(&self, msg: &Message, before: usize) -> bool {
499        if self.group_gap_secs == 0 || before == 0 {
500            return false;
501        }
502        let Some(prev) = self.rows.get(before - 1) else {
503            return false;
504        };
505        // Direction breaks a run before identity does.
506        //
507        // In a private-message window both halves of a conversation with
508        // yourself have you as the sender, so identity cannot separate
509        // them and grouping collapses "you said / they said / you said"
510        // into one block — losing the alternation, which is the content.
511        // Direction can separate them: it is a property of which path
512        // produced the row, not of who is named on it.
513        if prev.msg.flags.contains(MessageFlags::OUTGOING)
514            != msg.flags.contains(MessageFlags::OUTGOING)
515        {
516            return false;
517        }
518        let (Some(a), Some(b)) = (prev.msg.group_key(), msg.group_key()) else {
519            return false;
520        };
521        if a != b {
522            return false;
523        }
524        // Timestamps are seconds; a message that predates the one above
525        // it (history interleaving, clock skew) is not a continuation.
526        let dt = msg.timestamp - prev.msg.timestamp;
527        (0..=self.group_gap_secs).contains(&dt)
528    }
529
530    /// Recompute the GROUPED flag for `rows` (clamped to the buffer).
531    ///
532    /// Needed because the flag is a property of a message's *neighbours*.
533    /// Trimming can delete a run's head, leaving rows that suppress their
534    /// nick with nothing above them to have shown it — a speaker's
535    /// messages appearing anonymously. Inserting can split a run the same
536    /// way.
537    ///
538    /// The flag depends only on the row directly above
539    /// (`groups_with_previous`), so a change touches at most the rows whose
540    /// predecessor changed. Callers pass exactly those; walking to the end
541    /// made every trim and every history insert cost O(scrollback).
542    fn regroup_rows(&mut self, rows: std::ops::Range<usize>, measure: &dyn TextMeasure) {
543        for i in rows.start..rows.end.min(self.rows.len()) {
544            let want = {
545                let msg = &self.rows[i].msg;
546                self.groups_with_previous(msg, i)
547            };
548            let had = self.rows[i].msg.flags.contains(MessageFlags::GROUPED);
549            if want == had {
550                continue;
551            }
552            let f = &mut self.rows[i].msg.flags;
553            *f = if want {
554                f.union(MessageFlags::GROUPED)
555            } else {
556                MessageFlags(f.0 & !MessageFlags::GROUPED.0)
557            };
558            // The gutter appears or disappears, so the row's height and
559            // layout are both stale.
560            //
561            // Re-*estimate* rather than keeping the old height as the new
562            // estimate. Ungrouping adds a gutter line, so the stale value
563            // is too small; carrying it forward under-reports
564            // total_height and leaves the scrollbar's upper bound short
565            // until enough rows happen to be laid out for real. That is
566            // the kind of bug that looks like "scrolling stops early
567            // sometimes" and is miserable to trace back here.
568            self.rows[i].layout = None;
569            let params = self.layout_params();
570            let h = estimate_height(&self.rows[i].msg, &params, measure);
571            self.index.set_height(i, h, false);
572        }
573    }
574
575    fn alloc_id(&mut self) -> MessageId {
576        let id = MessageId(self.next_id);
577        self.next_id += 1;
578        id
579    }
580
581    // ---- geometry ---------------------------------------------------
582
583    /// Change the content width. Invalidates layout without recomputing
584    /// it — the whole point of the lazy cache.
585    pub fn set_width(&mut self, width: u32) {
586        if width == self.params.width {
587            return;
588        }
589        self.params.width = width;
590        self.generation.width = width;
591        self.index.invalidate_all_measurements();
592    }
593
594    pub fn set_font_generation(&mut self, font: u32) {
595        if font == self.generation.font {
596            return;
597        }
598        self.generation.font = font;
599        self.index.invalidate_all_measurements();
600    }
601
602    /// Zoom, in per-mille (1000 = 100%).
603    pub fn set_zoom_permille(&mut self, zoom: u32) {
604        if zoom == self.generation.zoom_permille {
605            return;
606        }
607        self.generation.zoom_permille = zoom;
608        self.index.invalidate_all_measurements();
609    }
610
611    pub fn set_word_wrap(&mut self, on: bool) {
612        if on == self.params.word_wrap {
613            return;
614        }
615        self.params.word_wrap = on;
616        self.invalidate_layout();
617    }
618
619    /// Edge length of the avatar slot in the gutter; 0 turns avatars
620    /// off. Invalidates every layout, since it changes the gutter width
621    /// and the height of every group head.
622    pub fn set_avatar_size(&mut self, px: u32) {
623        if px == self.params.avatar_size {
624            return;
625        }
626        self.params.avatar_size = px;
627        // The gutter has to be re-reconciled: the avatar contributes to
628        // its width, so turning avatars off should let it shrink back
629        // rather than stay padded out for icons that are gone.
630        self.reset_indent();
631        self.invalidate_layout();
632    }
633
634    /// Two-column mode.
635    pub fn set_indent(&mut self, on: bool) {
636        if on == self.params.indent {
637            return;
638        }
639        self.params.indent = on;
640        self.reset_indent();
641        self.invalidate_layout();
642    }
643
644    /// Width to reserve for the timestamp column; 0 turns it off.
645    pub fn set_stamp_width(&mut self, px: u32) {
646        if px == self.params.stamp_width {
647            return;
648        }
649        self.params.stamp_width = px;
650        // The gutter has to be re-reconciled from scratch: it may need to
651        // grow for a wider stamp, and when the stamp goes away it should
652        // shrink back rather than stay padded out.
653        self.reset_indent();
654        self.invalidate_layout();
655    }
656
657    pub fn stamp_width(&self) -> u32 {
658        self.params.stamp_width
659    }
660
661    /// Cap on the gutter width.
662    pub fn set_max_indent(&mut self, px: u32) {
663        if px == self.params.max_indent {
664            return;
665        }
666        self.params.max_indent = px;
667        self.indent_width = self.indent_width.min(px);
668        self.invalidate_layout();
669    }
670
671    /// Drop every cached layout, keeping heights as estimates.
672    ///
673    /// The generation key can't express "the params changed" — it tracks
674    /// width, font, theme and zoom, and a geometry knob like indent mode
675    /// is none of those. Rather than widen the key for two setters that
676    /// fire once at construction, clear the caches directly. Heights
677    /// survive as estimates, so this is still O(1) work now and
678    /// O(visible) on the next draw.
679    fn invalidate_layout(&mut self) {
680        for r in &mut self.rows {
681            r.layout = None;
682        }
683        self.index.invalidate_all_measurements();
684    }
685
686    /// Ensure row `row` has a current layout, computing it if not.
687    /// Returns its height.
688    pub fn ensure_layout(&mut self, row: usize, measure: &dyn TextMeasure) -> u32 {
689        let gen = self.generation;
690        let mut params = self.params;
691        params.indent_width = self.indent_width;
692
693        let Some(r) = self.rows.get(row) else {
694            return 0;
695        };
696        if let Some(l) = &r.layout {
697            if l.generation == gen {
698                return l.height;
699            }
700        }
701        let layout = layout_message(&self.rows[row].msg, &params, gen, measure);
702
703        // A wider nick than any seen so far widens the shared gutter, so
704        // every *other* row's layout is stale. Rare — it settles within
705        // the first few messages — and lazily repaired.
706        //
707        // The row we just laid out has to be redone rather than merely
708        // invalidated: it was measured against the old, narrower gutter,
709        // and dropping it would leave `layout_at(row)` returning None
710        // immediately after an `ensure_layout(row)` call, which is a
711        // contract violation the caller has no way to recover from.
712        if layout.natural_indent > self.indent_width && !self.indent_pinned {
713            self.indent_width = layout.natural_indent;
714            for r in &mut self.rows {
715                r.layout = None;
716            }
717            self.index.invalidate_all_measurements();
718            params.indent_width = self.indent_width;
719            let layout = layout_message(&self.rows[row].msg, &params, gen, measure);
720            let height = layout.height;
721            self.rows[row].layout = Some(Box::new(layout));
722            self.index.set_height(row, height, true);
723            return height;
724        }
725
726        let height = layout.height;
727        // Reuse the allocation when the row is being relaid out.
728        match &mut self.rows[row].layout {
729            Some(b) => **b = layout,
730            slot => *slot = Some(Box::new(layout)),
731        }
732        self.index.set_height(row, height, true);
733        height
734    }
735
736    /// Lay out every row intersecting `[y, y + height)` and return their
737    /// positions.
738    ///
739    /// **Every returned row is guaranteed to have a current layout.**
740    /// That guarantee needs defending, because laying a row out can
741    /// invalidate the rows already done: meeting a wider nick widens the
742    /// shared gutter, which makes every other row's cached layout stale.
743    /// Without the retry below, the rows laid out earlier in the pass
744    /// come back with `layout_at(row) == None`, the view skips them, and
745    /// the first paint of a fresh buffer is visibly shredded — while the
746    /// next message, by which time the gutter has settled, looks fine.
747    ///
748    /// The gutter only ever grows and is capped by `max_indent`, so this
749    /// converges fast; the bound is belt-and-braces.
750    pub fn ensure_visible(
751        &mut self,
752        y: u64,
753        viewport_height: u32,
754        measure: &dyn TextMeasure,
755    ) -> Vec<usize> {
756        const MAX_SETTLE_PASSES: usize = 4;
757        for _ in 0..MAX_SETTLE_PASSES {
758            let indent_before = self.indent_width;
759            let rows = self.layout_visible_once(y, viewport_height, measure);
760            if self.indent_width == indent_before {
761                return rows;
762            }
763        }
764        // Didn't settle (shouldn't happen: the gutter is monotonic and
765        // capped). Do one final pass so the caller still gets laid-out
766        // rows rather than holes.
767        self.layout_visible_once(y, viewport_height, measure)
768    }
769
770    fn layout_visible_once(
771        &mut self,
772        y: u64,
773        viewport_height: u32,
774        measure: &dyn TextMeasure,
775    ) -> Vec<usize> {
776        let mut out = Vec::new();
777        let Some(first) = self.index.locate(y) else {
778            return out;
779        };
780        let mut row = first.row;
781        let mut consumed = u64::from(self.index.height_at(row)) - u64::from(first.offset);
782        self.ensure_layout(row, measure);
783        out.push(row);
784        while consumed < u64::from(viewport_height) && row + 1 < self.rows.len() {
785            row += 1;
786            self.ensure_layout(row, measure);
787            out.push(row);
788            consumed += u64::from(self.index.height_at(row));
789        }
790        out
791    }
792
793    /// Text of one of a message's sources, for hit-testing and copying.
794    pub fn source_text(&self, row: usize, source: LineSource) -> Option<&str> {
795        self.source_text_for(&self.rows.get(row)?.msg, source)
796    }
797
798    fn source_text_for<'a>(&self, msg: &'a Message, source: LineSource) -> Option<&'a str> {
799        match source {
800            LineSource::Gutter => msg.gutter.as_ref().map(|g| g.text.as_str()),
801            LineSource::Block(bi) => match msg.blocks.get(bi)? {
802                Block::Text(p) => Some(p.text.as_str()),
803                Block::Quote { content, .. } => Some(content.text.as_str()),
804                Block::Code { text, .. } => Some(text.as_str()),
805                // An image contributes its alt text, so a selection
806                // dragged across a picture copies something meaningful
807                // instead of nothing.
808                Block::Image { alt, .. } => Some(alt.as_str()),
809            },
810        }
811    }
812
813    /// The link under a caret, as (href, visible text).
814    ///
815    /// Returned by value rather than by reference because the caller is
816    /// the widget, which needs to hold it across a popup.
817    /// The byte range of the link under `caret`, in its source's text.
818    ///
819    /// Split from `link_at` because the hover underline needs the extent
820    /// and not the target — and deriving the extent a second way is how
821    /// the underline and the click end up disagreeing about where a link
822    /// stops.
823    pub fn link_range_at(&self, caret: &Caret) -> Option<std::ops::Range<usize>> {
824        let row = self.row_of(caret.message)?;
825        let msg = &self.rows.get(row)?.msg;
826        let parsed = match caret.source {
827            LineSource::Gutter => msg.gutter.as_ref()?,
828            LineSource::Block(bi) => match msg.blocks.get(bi)? {
829                Block::Text(p) => p,
830                Block::Quote { content, .. } => content,
831                _ => return None,
832            },
833        };
834        parsed.link_at(caret.offset).map(|l| l.range.clone())
835    }
836
837    /// Byte extent of a row's gutter text, for the nick hover underline.
838    pub fn gutter_range(&self, id: MessageId) -> Option<std::ops::Range<usize>> {
839        let row = self.row_of(id)?;
840        let g = self.rows.get(row)?.msg.gutter.as_ref()?;
841        (!g.text.is_empty()).then_some(0..g.text.len())
842    }
843
844    /// The speaker of a row, if it has one.
845    pub fn speaker_of(&self, id: MessageId) -> Option<&crate::message::Speaker> {
846        let row = self.row_of(id)?;
847        self.rows.get(row)?.msg.speaker.as_ref()
848    }
849
850    pub fn link_at(&self, caret: &Caret) -> Option<(String, String)> {
851        let row = self.row_of(caret.message)?;
852        let msg = &self.rows.get(row)?.msg;
853        let parsed = match caret.source {
854            LineSource::Gutter => msg.gutter.as_ref()?,
855            LineSource::Block(bi) => match msg.blocks.get(bi)? {
856                Block::Text(p) => p,
857                Block::Quote { content, .. } => content,
858                _ => return None,
859            },
860        };
861        let link = parsed.link_at(caret.offset)?;
862        let label = parsed
863            .text
864            .get(link.range.clone())
865            .unwrap_or_default()
866            .to_string();
867        Some((link.href.clone(), label))
868    }
869
870    /// The whitespace-delimited word around a caret.
871    ///
872    /// Tokenised like xtext's `is_del` macro: space, newline, `<`, `>`
873    /// and NUL. The angle brackets are what let `<nick>` split into a
874    /// bare nick when it is double-clicked.
875    pub fn word_at(&self, caret: &Caret) -> Option<String> {
876        let (start, end) = self.word_bounds(caret)?;
877        let row = self.row_of(caret.message)?;
878        let text = self.source_text(row, caret.source)?;
879        Some(text[start..end].to_string())
880    }
881
882    /// Byte bounds of the word around a caret, in its own source.
883    ///
884    /// Shared by `word_at` (which the `word-click` emission needs as a
885    /// string) and double-click word-select, so the two can never
886    /// disagree about where a word begins.
887    pub fn word_bounds(&self, caret: &Caret) -> Option<(usize, usize)> {
888        let row = self.row_of(caret.message)?;
889        let text = self.source_text(row, caret.source)?;
890        if text.is_empty() {
891            return None;
892        }
893        let is_del = |c: char| c == ' ' || c == '\n' || c == '<' || c == '>' || c == '\0';
894        // Clamp to a char boundary before slicing. Carets from
895        // `hit_test` are already aligned, but this is reachable from the
896        // FFI with an arbitrary offset, and `&text[at..]` off a boundary
897        // panics — which unwinds into GTK and aborts.
898        let mut at = caret.offset.min(text.len());
899        while at > 0 && !text.is_char_boundary(at) {
900            at -= 1;
901        }
902
903        // A click *on* a delimiter yields no word, which is what xtext
904        // does: both its scan loops (xtext.c:2095, 2114) test `is_del`
905        // before stepping, so they collapse to an empty span. Returning
906        // the preceding word instead would make clicking the gap after a
907        // link activate it.
908        match text[at..].chars().next() {
909            Some(c) if !is_del(c) => {}
910            _ => return None,
911        }
912
913        let start = text[..at]
914            .rfind(is_del)
915            .map(|i| i + text[i..].chars().next().map_or(1, |c| c.len_utf8()))
916            .unwrap_or(0);
917        let end = text[at..]
918            .find(is_del)
919            .map(|i| at + i)
920            .unwrap_or(text.len());
921        if start >= end {
922            return None;
923        }
924        Some((start, end))
925    }
926
927    /// Double-click: select the word under the caret.
928    pub fn select_word(&self, caret: &Caret) -> Option<Selection> {
929        let (start, end) = self.word_bounds(caret)?;
930        Some(Selection::new(
931            Caret {
932                message: caret.message,
933                source: caret.source,
934                offset: start,
935            },
936            Caret {
937                message: caret.message,
938                source: caret.source,
939                offset: end,
940            },
941        ))
942    }
943
944    /// Triple-click: select the whole row, gutter included.
945    ///
946    /// The gutter is part of what the user sees on that line, so it is
947    /// part of what a "select this line" gesture should give them —
948    /// consistent with a whole-row selection dragged from above.
949    pub fn select_row(&self, row: usize) -> Option<Selection> {
950        let id = self.id_at(row)?;
951        let first = self.sources_of(row).first().copied()?;
952        let last = self.sources_of(row).last().copied()?;
953        let end_len = self.source_text(row, last).map_or(0, |t| t.len());
954        Some(Selection::new(
955            Caret {
956                message: id,
957                source: first,
958                offset: 0,
959            },
960            Caret {
961                message: id,
962                source: last,
963                offset: end_len,
964            },
965        ))
966    }
967
968    /// Every occurrence of `needle`, in reading order.
969    ///
970    /// Reading order means row by row, and within a row by
971    /// [`source_rank`](crate::select::source_rank) — the same order
972    /// `selected_rows` walks and the same order the eye does, so
973    /// stepping through matches never jumps backwards on screen.
974    ///
975    /// Searches the *model*, not the layout, so matches in rows that
976    /// have never been laid out are found too. That matters: the whole
977    /// point of search is to reach the part of the scrollback you
978    /// haven't scrolled to.
979    pub fn search(&self, needle: &str, case_sensitive: bool) -> Vec<crate::search::Match> {
980        let mut out = Vec::new();
981        if needle.is_empty() {
982            return out;
983        }
984        for row in 0..self.rows.len() {
985            let Some(id) = self.id_at(row) else { continue };
986            for source in self.sources_of(row) {
987                let Some(text) = self.source_text(row, source) else {
988                    continue;
989                };
990                for (start, end) in crate::search::find_all(text, needle, case_sensitive) {
991                    out.push(crate::search::Match {
992                        message: id,
993                        source,
994                        start,
995                        end,
996                    });
997                }
998            }
999        }
1000        out
1001    }
1002
1003    /// The speaker whose avatar covers content point `(x, y)`, if any.
1004    ///
1005    /// The avatar is painted from `LayoutCache::avatar`, not from a line
1006    /// box, so the ordinary caret hit-test cannot see it — clicking an
1007    /// icon found nothing at all. Asking the layout directly keeps the
1008    /// clickable area and the painted area the same rectangle by
1009    /// construction.
1010    pub fn avatar_at(&mut self, x: i32, y: u64) -> Option<(MessageId, u64)> {
1011        let hit = self.index.locate(y)?;
1012        let top = self.index.offset_of(hit.row);
1013        let row = self.rows.get(hit.row)?;
1014        let av = row.layout.as_ref()?.avatar?;
1015        let id = row.id;
1016        let local_y = y.checked_sub(top)? as i64;
1017        let (ax, ay, size) = (av.x as i64, av.y as i64, av.size as i64);
1018        if (x as i64) >= ax && (x as i64) < ax + size && local_y >= ay && local_y < ay + size {
1019            // The id comes back with the key because the caller needs
1020            // both and this has already found the row. Making it look
1021            // the row up again meant a second borrow of the buffer while
1022            // this one was still live — an instant RefCell panic on the
1023            // first avatar hover.
1024            Some((id, av.key))
1025        } else {
1026            None
1027        }
1028    }
1029
1030    /// A selection covering the whole buffer, or `None` when empty.
1031    ///
1032    /// The model owns what "everything" means, because the view kept
1033    /// getting it wrong: it hard-coded `Block(0)` at both ends, which
1034    /// skipped the first row's *gutter* and — once markdown started
1035    /// splitting a body into several blocks — stopped at the end of the
1036    /// first block of the last row, dropping any code block or quote
1037    /// after it.
1038    pub fn select_all(&self) -> Option<Selection> {
1039        if self.rows.is_empty() {
1040            return None;
1041        }
1042        let last_row = self.rows.len() - 1;
1043        let first_src = *self.sources_of(0).first()?;
1044        let last_src = *self.sources_of(last_row).last()?;
1045        let end = self.source_text(last_row, last_src).map_or(0, |t| t.len());
1046        Some(Selection::new(
1047            Caret {
1048                message: self.id_at(0)?,
1049                source: first_src,
1050                offset: 0,
1051            },
1052            Caret {
1053                message: self.id_at(last_row)?,
1054                source: last_src,
1055                offset: end,
1056            },
1057        ))
1058    }
1059
1060    /// Matches falling inside one row's one source, for the renderer.
1061    ///
1062    /// The renderer asks per source per row while drawing, so this is a
1063    /// filter over the result set rather than a fresh search.
1064    pub fn matches_in(
1065        all: &[crate::search::Match],
1066        id: MessageId,
1067        source: LineSource,
1068    ) -> Vec<crate::search::Match> {
1069        all.iter()
1070            .filter(|m| m.message == id && m.source == source)
1071            .copied()
1072            .collect()
1073    }
1074
1075    /// Scroll so `id` is on screen, centred if it isn't already visible.
1076    ///
1077    /// Leaves an already-visible row where it is. Yanking the viewport
1078    /// on every step would make walking a cluster of matches in one
1079    /// screenful feel like the view was fighting you.
1080    pub fn reveal(&mut self, id: MessageId, viewport_height: u32, measure: &dyn TextMeasure) {
1081        self.reindex();
1082        let Some(row) = self.row_of(id) else { return };
1083        // The offset is only meaningful once the row has a real height
1084        // rather than an estimate, so measure it first.
1085        self.ensure_layout(row, measure);
1086        let top = self.index.offset_of(row);
1087        let h = self.index.height_at(row) as u64;
1088        let cur = self.scroll_offset(viewport_height);
1089        let vh = viewport_height as u64;
1090        if top >= cur && top + h <= cur + vh {
1091            return;
1092        }
1093        let target = if h >= vh {
1094            top
1095        } else {
1096            top.saturating_sub((vh - h) / 2)
1097        };
1098        self.scroll_to(target, viewport_height, 0);
1099    }
1100
1101    /// The settled gutter width. 0 when not in indent mode.
1102    pub fn indent_width(&self) -> u32 {
1103        self.indent_width
1104    }
1105
1106    /// Whether the gutter has been pinned by a separator drag.
1107    pub fn indent_pinned(&self) -> bool {
1108        self.indent_pinned
1109    }
1110
1111    /// Pin the gutter to `px`, as a separator drag does.
1112    ///
1113    /// Deliberately not clamped to `max_indent`: that cap governs how
1114    /// far the gutter may grow *on its own*, and a user dragging the
1115    /// separator is saying something the cap has no business overruling.
1116    /// The caller clamps to the viewport instead. Returns whether
1117    /// anything moved.
1118    pub fn set_indent_width(&mut self, px: u32) -> bool {
1119        let px = px.max(MIN_INDENT);
1120        self.indent_pinned = true;
1121        if px == self.indent_width {
1122            return false;
1123        }
1124        self.indent_width = px;
1125        self.invalidate_layout();
1126        true
1127    }
1128
1129    /// Drop the auto-sized gutter so it re-reconciles from scratch.
1130    ///
1131    /// A no-op once pinned: every caller of this is some *other* knob
1132    /// changing (buffer cleared, stamp width changed, indent toggled),
1133    /// and none of them is a reason to discard a width the user set by
1134    /// hand.
1135    fn reset_indent(&mut self) {
1136        if !self.indent_pinned {
1137            self.indent_width = 0;
1138        }
1139    }
1140
1141    /// Release the pin and let the gutter auto-size again.
1142    pub fn unpin_indent(&mut self) {
1143        if !self.indent_pinned {
1144            return;
1145        }
1146        self.indent_pinned = false;
1147        self.indent_width = 0;
1148        self.invalidate_layout();
1149    }
1150
1151    /// Map a content-space pixel to a document position.
1152    ///
1153    /// `x` / `y` are in content coordinates (the view subtracts its own
1154    /// padding first). `y` is absolute within the buffer, not
1155    /// viewport-relative.
1156    ///
1157    /// Returns `None` only for an empty buffer; a point past the end
1158    /// clamps to the last row, because a drag that runs off the bottom
1159    /// should select to the end rather than stop tracking.
1160    pub fn hit_test(&mut self, x: i32, y: u64, measure: &dyn TextMeasure) -> Option<Caret> {
1161        let hit = self.index.locate(y)?;
1162        let row = hit.row;
1163        self.ensure_layout(row, measure);
1164        let id = self.id_at(row)?;
1165        let layout = self.layout_at(row)?;
1166
1167        // The line whose band contains the point.
1168        //
1169        // Several boxes can share a y band — the gutter and the first
1170        // body line always do — so x has to break the tie, otherwise
1171        // every click on row 0 resolves to whichever was pushed first
1172        // and the other becomes unselectable.
1173        let in_band: Vec<&crate::wrap::LineBox> = layout
1174            .lines
1175            .iter()
1176            .filter(|l| hit.offset >= l.y && hit.offset < l.y + l.height)
1177            .collect();
1178        let line = in_band
1179            .iter()
1180            .copied()
1181            .find(|l| {
1182                let w = self
1183                    .source_text_for(&self.rows[row].msg, l.source)
1184                    .and_then(|t| t.get(l.range.clone()))
1185                    .map(|slice| measure.run_width(slice, Style::default()))
1186                    .unwrap_or(0);
1187                x >= l.x as i32 && x < (l.x + w) as i32
1188            })
1189            // No box owns the x: fall back to the nearest by distance,
1190            // so a click in the gap between gutter and body still picks
1191            // something sensible rather than nothing.
1192            .or_else(|| {
1193                in_band
1194                    .iter()
1195                    .copied()
1196                    .min_by_key(|l| (x - l.x as i32).abs())
1197            })
1198            .or_else(|| {
1199                if hit.offset < layout.lines.first().map_or(0, |l| l.y) {
1200                    layout.lines.first()
1201                } else {
1202                    layout.lines.last()
1203                }
1204            })?;
1205
1206        let source = line.source;
1207        let range = line.range.clone();
1208        let line_x = line.x as i32;
1209        let text = self.source_text(row, source)?;
1210        let slice = text.get(range.clone()).unwrap_or("");
1211
1212        // Within the line, find the byte the x lands on. Left of the
1213        // line start selects from its beginning; right of the end
1214        // selects through it, which is what makes dragging past the end
1215        // of a short line feel right.
1216        let rel = x - line_x;
1217        let offset = if rel <= 0 {
1218            range.start
1219        } else {
1220            let (fit, _) = measure.fit_prefix(slice, Style::default(), rel as u32);
1221            (range.start + fit).min(range.end)
1222        };
1223
1224        Some(Caret {
1225            message: id,
1226            source,
1227            offset,
1228        })
1229    }
1230
1231    /// How much of `row` a selection covers.
1232    pub fn row_selection(&self, row: usize, sel: &Selection) -> RowSelection {
1233        if sel.is_empty() {
1234            return RowSelection::None;
1235        }
1236        let (start, end) = sel.ordered(|id| self.row_of(id));
1237        let (Some(sr), Some(er)) = (self.row_of(start.message), self.row_of(end.message)) else {
1238            return RowSelection::None;
1239        };
1240        if row < sr || row > er {
1241            return RowSelection::None;
1242        }
1243        if row > sr && row < er {
1244            return RowSelection::All;
1245        }
1246        // A boundary row. The covered span can begin in one source and
1247        // end in another (gutter → body), so both endpoints are carried
1248        // and the caller resolves per source.
1249        let last = self.last_source(row);
1250        let (from, to) = if sr == er {
1251            ((start.source, start.offset), (end.source, end.offset))
1252        } else if row == sr {
1253            // Starts here, runs to the end of the row.
1254            (
1255                (start.source, start.offset),
1256                (last, self.source_text(row, last).map_or(0, |t| t.len())),
1257            )
1258        } else {
1259            // Ends here, having begun above.
1260            ((self.first_source(row), 0), (end.source, end.offset))
1261        };
1262        RowSelection::Partial {
1263            start: from,
1264            end: to,
1265        }
1266    }
1267
1268    /// The row's sources in visual order: gutter first, then blocks.
1269    pub fn sources_of(&self, row: usize) -> Vec<LineSource> {
1270        let mut out = Vec::new();
1271        let Some(msg) = self.message_at(row) else {
1272            return out;
1273        };
1274        if msg.gutter.as_ref().is_some_and(|g| !g.text.is_empty()) {
1275            out.push(LineSource::Gutter);
1276        }
1277        for i in 0..msg.blocks.len() {
1278            out.push(LineSource::Block(i));
1279        }
1280        out
1281    }
1282
1283    fn first_source(&self, row: usize) -> LineSource {
1284        self.sources_of(row)
1285            .first()
1286            .copied()
1287            .unwrap_or(LineSource::Block(0))
1288    }
1289
1290    fn last_source(&self, row: usize) -> LineSource {
1291        self.sources_of(row)
1292            .last()
1293            .copied()
1294            .unwrap_or(LineSource::Block(0))
1295    }
1296
1297    /// The byte range of `source` covered by a row selection, if any.
1298    ///
1299    /// The one place that resolves a possibly-cross-source `Partial`
1300    /// against a single text, so the view and `selected_text` cannot
1301    /// disagree about what is highlighted versus what gets copied.
1302    pub fn covered_range(
1303        &self,
1304        row: usize,
1305        source: LineSource,
1306        sel: &RowSelection,
1307    ) -> Option<std::ops::Range<usize>> {
1308        let len = self.source_text(row, source).map_or(0, |t| t.len());
1309        match sel {
1310            RowSelection::None => None,
1311            RowSelection::All => Some(0..len),
1312            RowSelection::Partial { start, end } => {
1313                let rank = crate::select::source_rank(source);
1314                let (sr, so) = *start;
1315                let (er, eo) = *end;
1316                let (sr, er) = (
1317                    crate::select::source_rank(sr),
1318                    crate::select::source_rank(er),
1319                );
1320                if rank < sr || rank > er {
1321                    return None;
1322                }
1323                let from = if rank == sr { so.min(len) } else { 0 };
1324                let to = if rank == er { eo.min(len) } else { len };
1325                if from < to {
1326                    Some(from..to)
1327                } else {
1328                    None
1329                }
1330            }
1331        }
1332    }
1333
1334    /// The selected text of each covered row, as `(row, text)`.
1335    ///
1336    /// Exposed per row because a row's own text may contain hard
1337    /// newlines — the wrap engine supports them — so a caller cannot
1338    /// recover row boundaries by splitting the joined string. The
1339    /// autocopy-timestamp path needs exactly that: it prefixes each
1340    /// *row* with that row's stamp, and iterating `lines()` over the
1341    /// joined output drifts onto the wrong rows the moment any selected
1342    /// message spans more than one line.
1343    pub fn selected_rows(&self, sel: &Selection) -> Vec<(usize, String)> {
1344        if sel.is_empty() {
1345            return Vec::new();
1346        }
1347        let (start, end) = sel.ordered(|id| self.row_of(id));
1348        let (Some(sr), Some(er)) = (self.row_of(start.message), self.row_of(end.message)) else {
1349            return Vec::new();
1350        };
1351        let mut out = Vec::new();
1352        for row in sr..=er {
1353            // Walk the row's sources in visual order and take whatever
1354            // each contributes. One path for every case, so the gutter
1355            // is copied when it is selected and only then.
1356            let rsel = self.row_selection(row, sel);
1357            let mut text = String::new();
1358            let mut prev: Option<LineSource> = None;
1359            // A row can be *covered* and still contribute nothing — the
1360            // first and last rows of a selection that starts at the end
1361            // of one row and ends at the start of another. Those must
1362            // not be reported: they become blank lines in the copied
1363            // text, and AUTOCOPY_STAMP would prefix a timestamp to a row
1364            // with no content. A row that is genuinely empty is a
1365            // different thing and does belong in the output as a blank
1366            // line, so the test is "had text and none of it was
1367            // selected", not "produced no text".
1368            let mut had_text = false;
1369            for source in self.sources_of(row) {
1370                // Note the order: `had_text` has to be established
1371                // before any early-continue, or a row whose coverage is
1372                // empty everywhere looks indistinguishable from a row
1373                // that is empty.
1374                let Some(t) = self.source_text(row, source) else {
1375                    continue;
1376                };
1377                had_text |= !t.is_empty();
1378                let Some(range) = self.covered_range(row, source, &rsel) else {
1379                    continue;
1380                };
1381                let Some(slice) = t.get(range) else { continue };
1382                if slice.is_empty() {
1383                    continue;
1384                }
1385                // Separator matches Message::to_plain_text, which is
1386                // what whole-row copying used before this path existed:
1387                // a space between the gutter and the body (they read as
1388                // one line), a newline between body blocks (they are
1389                // distinct blocks and collapsing them to spaces would
1390                // run a code block into the paragraph after it).
1391                match prev {
1392                    None => {}
1393                    Some(LineSource::Gutter) => text.push(' '),
1394                    Some(LineSource::Block(_)) => text.push('\n'),
1395                }
1396                text.push_str(slice);
1397                prev = Some(source);
1398            }
1399            if text.is_empty() && had_text {
1400                continue;
1401            }
1402            out.push((row, text));
1403        }
1404        out
1405    }
1406
1407    /// The selected text, rows joined by newlines.
1408    pub fn selected_text(&self, sel: &Selection) -> String {
1409        self.selected_rows(sel)
1410            .into_iter()
1411            .map(|(_, t)| t)
1412            .collect::<Vec<_>>()
1413            .join("\n")
1414    }
1415
1416    pub fn layout_at(&self, row: usize) -> Option<&LayoutCache> {
1417        self.rows.get(row).and_then(|r| r.layout.as_deref())
1418    }
1419
1420    pub fn total_height(&mut self) -> u64 {
1421        self.index.total_height()
1422    }
1423
1424    pub fn index_mut(&mut self) -> &mut HeightIndex {
1425        &mut self.index
1426    }
1427
1428    /// The scroll adjustment value the current anchor implies.
1429    pub fn scroll_offset(&mut self, viewport_height: u32) -> u64 {
1430        // Only the anchored row is ever asked for.
1431        let row = self.anchor.message.and_then(|id| self.locate(id));
1432        AnchorResolver::to_pixels(&self.anchor, &mut self.index, viewport_height, |_| row)
1433    }
1434
1435    /// The row `id` names, from a remembered row when `pos` is dirty and
1436    /// the id was asked for recently, else by rebuilding `pos`.
1437    fn locate(&mut self, id: MessageId) -> Option<usize> {
1438        if !self.pos_dirty {
1439            return self.row_of(id);
1440        }
1441        let known = self
1442            .hints
1443            .iter()
1444            .flatten()
1445            .find(|&&(h, r)| h == id && self.rows.get(r).is_some_and(|row| row.id == id))
1446            .map(|&(_, r)| r);
1447        if known.is_some() {
1448            return known;
1449        }
1450        self.reindex();
1451        let row = self.row_of(id)?;
1452        self.hints = [Some((id, row)), self.hints[0].filter(|&(h, _)| h != id)];
1453        Some(row)
1454    }
1455
1456    /// Rows at `from` and below moved by `delta`: move the hints with them.
1457    fn shift_hints(&mut self, from: usize, delta: isize) {
1458        for (_, r) in self.hints.iter_mut().flatten() {
1459            if *r >= from {
1460                *r = r.saturating_add_signed(delta);
1461            }
1462        }
1463    }
1464
1465    /// Row `row` is gone: drop a hint that named it.
1466    fn forget_hint(&mut self, row: usize) {
1467        for h in &mut self.hints {
1468            if h.is_some_and(|(_, r)| r == row) {
1469                *h = None;
1470            }
1471        }
1472    }
1473
1474    /// Re-anchor from a pixel position — a scrollbar drag.
1475    pub fn scroll_to(&mut self, y: u64, viewport_height: u32, follow_slop: u32) {
1476        // Borrow the rows rather than collecting their ids: a copy here
1477        // made every scroll cost O(scrollback).
1478        let rows = &self.rows;
1479        self.anchor =
1480            AnchorResolver::from_pixels(y, &mut self.index, viewport_height, follow_slop, |row| {
1481                rows.get(row).map(|r| r.id)
1482            });
1483    }
1484
1485    /// Pin to the bottom and resume following.
1486    pub fn scroll_to_bottom(&mut self) {
1487        self.anchor = ScrollAnchor::bottom();
1488    }
1489
1490    pub fn is_following(&self) -> bool {
1491        self.anchor.gravity == Gravity::Bottom
1492    }
1493}