Skip to main content

rotulus_layout/
anchor.rs

1//! Scroll position, expressed as a row rather than a pixel.
2//!
3//! The single most load-bearing design decision in the engine. A raw
4//! pixel scroll value is only meaningful relative to a particular set of
5//! row heights, and every interesting thing that happens to a chat
6//! buffer changes those heights: a resize re-wraps, a zoom rescales, an
7//! image finishes decoding and a 16-pixel placeholder becomes 240
8//! pixels, a history batch prepends 50 rows above the viewport, a
9//! scrollback trim drops rows off the top.
10//!
11//! Anchoring to `(row, offset within it)` makes all of those free.
12//! Compare xtext, which hand-patches each case separately:
13//! `gtk_xtext_insert_indent_before` (xtext.c:5720) bumps `pagetop_line`,
14//! `last_pixel_pos`, `old_value` and the adjustment by the inserted
15//! row's subline count to approximate "don't jump"; the trim path does
16//! the mirror-image decrement; and a font change just accepts the jump.
17//!
18//! It is also what makes estimated heights ([`crate::index`]) tolerable:
19//! when an estimate is replaced by a real measurement the total height
20//! changes, so the scrollbar thumb moves — but the anchored row does
21//! not, so the *text the user is reading* stays put. Thumb drift is
22//! survivable; content jumping is not.
23
24use crate::index::HeightIndex;
25use crate::message::MessageId;
26
27/// What the viewport is pinned to.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Gravity {
30    /// Follow new messages. The common case: the user is at the bottom
31    /// reading live chat, and appends should scroll.
32    Bottom,
33    /// Hold the anchored row still. The user has scrolled up.
34    Free,
35}
36
37/// The viewport's position.
38#[derive(Clone, Copy, PartialEq, Eq, Debug)]
39pub struct ScrollAnchor {
40    /// The row the viewport's top edge sits in. `None` with
41    /// [`Gravity::Bottom`] means "pinned to the very bottom", which is
42    /// the initial state and the state after every append while
43    /// following.
44    pub message: Option<MessageId>,
45    /// Pixels from the top of that row down to the viewport's top edge.
46    pub offset: u32,
47    pub gravity: Gravity,
48}
49
50impl Default for ScrollAnchor {
51    fn default() -> Self {
52        ScrollAnchor {
53            message: None,
54            offset: 0,
55            gravity: Gravity::Bottom,
56        }
57    }
58}
59
60impl ScrollAnchor {
61    /// Pinned to the bottom, following new messages.
62    pub fn bottom() -> ScrollAnchor {
63        ScrollAnchor::default()
64    }
65
66    pub fn is_following(&self) -> bool {
67        self.gravity == Gravity::Bottom
68    }
69}
70
71/// Resolves anchors against a live index. Stateless — the buffer calls
72/// these as associated functions.
73#[derive(Debug)]
74pub struct AnchorResolver;
75
76impl AnchorResolver {
77    /// The pixel value the scroll adjustment should report.
78    ///
79    /// `row_of` maps a [`MessageId`] to its current row position, which
80    /// only the buffer knows. Returns a value already clamped to
81    /// `[0, total - viewport]`.
82    pub fn to_pixels(
83        anchor: &ScrollAnchor,
84        index: &mut HeightIndex,
85        viewport_height: u32,
86        row_of: impl Fn(MessageId) -> Option<usize>,
87    ) -> u64 {
88        let total = index.total_height();
89        let max = total.saturating_sub(u64::from(viewport_height));
90
91        match anchor.message {
92            // Following: always the bottom.
93            None => max,
94            Some(id) => match row_of(id) {
95                Some(row) => {
96                    let base = index.offset_of(row);
97                    let y = base + u64::from(anchor.offset);
98                    y.min(max)
99                }
100                // The anchored row was trimmed or cleared out from under
101                // us. Falling back to the bottom is right: a trim only
102                // ever removes the oldest live rows, so the content the
103                // user was looking at is gone and the least surprising
104                // place to be is where new messages arrive.
105                None => max,
106            },
107        }
108    }
109
110    /// Build an anchor from a pixel position — what a scrollbar drag
111    /// produces.
112    ///
113    /// Snapping to `Gravity::Bottom` when the position is within
114    /// `follow_slop` of the end is what makes "scroll to the bottom and
115    /// it starts following again" work, and it is why the slop exists at
116    /// all: landing one pixel short of the end should not silently stop
117    /// following.
118    pub fn from_pixels(
119        y: u64,
120        index: &mut HeightIndex,
121        viewport_height: u32,
122        follow_slop: u32,
123        id_of_row: impl Fn(usize) -> Option<MessageId>,
124    ) -> ScrollAnchor {
125        let total = index.total_height();
126        let max = total.saturating_sub(u64::from(viewport_height));
127        if y + u64::from(follow_slop) >= max {
128            return ScrollAnchor::bottom();
129        }
130        match index.locate(y) {
131            Some(hit) => ScrollAnchor {
132                message: id_of_row(hit.row),
133                offset: hit.offset,
134                gravity: Gravity::Free,
135            },
136            None => ScrollAnchor::bottom(),
137        }
138    }
139}