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}