返回 DeepSeek-TUI-2026
scrolling.rs
根目录 / crates / tui / src / tui / scrolling.rs
1 //! Scroll state tracking for transcript rendering.
2 //!
3 //! The transcript view uses a flat line-index scroll model: a single `offset`
4 //! into the rendered line-meta buffer points at the top visible line, with
5 //! `usize::MAX` reserved as a sentinel meaning "stuck to the live tail."
6 //!
7 //! Why a flat offset, not cell anchors? An earlier design anchored the
8 //! viewport to a `(cell_index, line_in_cell)` pair on the assumption that
9 //! the cell list was append-only. It is not — content rewrites (RLM `repl`
10 //! blocks expanding into `Thinking + Text`, tool result replacements, and
11 //! compaction) can renumber or remove cells underneath the user. When the
12 //! anchor cell vanished the viewport teleported to the bottom (issue #56)
13 //! or "got stuck" because the next keypress would resolve from `max_start`.
14 //!
15 //! Codex's pager uses the same line-offset shape; see
16 //! `codex-rs/tui/src/pager_overlay.rs::PagerView`.
17
18 use std::time::{Duration, Instant};
19
20 // === Transcript Line Metadata ===
21
22 /// Metadata describing how rendered transcript lines map to history cells.
23 ///
24 /// The scroll state itself does not consult this — it only stores a flat
25 /// line offset — but other render-time helpers (selection painting,
26 /// send-flash, jump-to-tool, scrollbar percent) still need the
27 /// line→cell mapping the cache exposes.
28 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 pub enum TranscriptLineMeta {
30 CellLine {
31 cell_index: usize,
32 line_in_cell: usize,
33 },
34 Spacer,
35 }
36
37 impl TranscriptLineMeta {
38 /// Return cell/line indices if this entry is a cell line.
39 #[must_use]
40 pub fn cell_line(&self) -> Option<(usize, usize)> {
41 match *self {
42 TranscriptLineMeta::CellLine {
43 cell_index,
44 line_in_cell,
45 } => Some((cell_index, line_in_cell)),
46 TranscriptLineMeta::Spacer => None,
47 }
48 }
49 }
50
51 // === Transcript Scroll State ===
52
53 /// Sentinel offset meaning "stuck to live tail" — the renderer translates
54 /// this to `max_start` at draw time, so newly appended lines pull the view
55 /// down with them.
56 const TAIL_SENTINEL: usize = usize::MAX;
57
58 /// Flat line-offset scroll state for the transcript view.
59 ///
60 /// Stores the index of the top visible line into the cache's `line_meta`
61 /// buffer, or [`TAIL_SENTINEL`] (`usize::MAX`) to mean "stuck to bottom."
62 /// The renderer resolves the sentinel against the current line count and
63 /// viewport height every frame, so content rewrites simply clamp the
64 /// user's offset rather than triggering anchor recovery heuristics.
65 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
66 pub struct TranscriptScroll {
67 offset: usize,
68 }
69
70 impl Default for TranscriptScroll {
71 /// Default state is "stuck to live tail" — matches the historical
72 /// `TranscriptScroll::ToBottom` behaviour callers already depend on.
73 fn default() -> Self {
74 Self::to_bottom()
75 }
76 }
77
78 impl TranscriptScroll {
79 /// State that follows the live tail (default).
80 #[must_use]
81 pub const fn to_bottom() -> Self {
82 Self {
83 offset: TAIL_SENTINEL,
84 }
85 }
86
87 /// State pinned to a specific line index.
88 #[must_use]
89 pub const fn at_line(offset: usize) -> Self {
90 Self { offset }
91 }
92
93 /// Returns true when the view is following the live tail.
94 #[must_use]
95 pub const fn is_at_tail(self) -> bool {
96 self.offset == TAIL_SENTINEL
97 }
98
99 /// Resolve the scroll state to a concrete top line index.
100 ///
101 /// `max_start` is `total_lines.saturating_sub(visible_lines)`. The
102 /// returned `Self` is the canonicalized state — if the resolved top
103 /// reached the tail (or the transcript fits in one screen) we collapse
104 /// to [`TranscriptScroll::to_bottom`], so the caller can treat the
105 /// returned state as authoritative.
106 ///
107 /// `line_meta` is accepted for API compatibility with the previous
108 /// cell-anchored implementation. It is unused here because the flat
109 /// offset model needs no cell-index lookup; we just clamp.
110 #[must_use]
111 pub fn resolve_top(self, line_meta: &[TranscriptLineMeta], max_start: usize) -> (Self, usize) {
112 let _ = line_meta;
113 if self.offset == TAIL_SENTINEL {
114 return (Self::to_bottom(), max_start);
115 }
116 let top = self.offset.min(max_start);
117 if top >= max_start {
118 (Self::to_bottom(), max_start)
119 } else {
120 (Self::at_line(top), top)
121 }
122 }
123
124 /// Apply a scroll delta and return the updated state.
125 ///
126 /// `delta_lines` is signed: negative scrolls up (toward the start),
127 /// positive scrolls down (toward the tail). When the resolved offset
128 /// hits `max_start` we snap to [`TranscriptScroll::to_bottom`] so
129 /// subsequent appended content pulls the view along.
130 ///
131 /// `line_meta` is accepted for API compatibility; only its length is
132 /// consulted. `visible_lines` controls the page size for clamping.
133 #[must_use]
134 pub fn scrolled_by(
135 self,
136 delta_lines: i32,
137 line_meta: &[TranscriptLineMeta],
138 visible_lines: usize,
139 ) -> Self {
140 if delta_lines == 0 {
141 return self;
142 }
143
144 let total_lines = line_meta.len();
145 if total_lines <= visible_lines {
146 // Whole transcript fits; only "tail" is meaningful.
147 return Self::to_bottom();
148 }
149
150 let max_start = total_lines.saturating_sub(visible_lines);
151 let current_top = if self.offset == TAIL_SENTINEL {
152 max_start
153 } else {
154 self.offset.min(max_start)
155 };
156
157 let new_top = if delta_lines < 0 {
158 current_top.saturating_sub(delta_lines.unsigned_abs() as usize)
159 } else {
160 let delta = usize::try_from(delta_lines).unwrap_or(usize::MAX);
161 current_top.saturating_add(delta).min(max_start)
162 };
163
164 if new_top >= max_start {
165 Self::to_bottom()
166 } else {
167 Self::at_line(new_top)
168 }
169 }
170
171 /// Pin the scroll state to a specific line index in the rendered
172 /// transcript (saturating to the meta buffer length).
173 ///
174 /// Returns `None` if `line_meta` is empty (caller should default to
175 /// [`TranscriptScroll::to_bottom`] in that case).
176 #[must_use]
177 pub fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option<Self> {
178 if line_meta.is_empty() {
179 return None;
180 }
181 let clamped = start.min(line_meta.len().saturating_sub(1));
182 Some(Self::at_line(clamped))
183 }
184 }
185
186 /// Direction for mouse scroll input.
187 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
188 pub enum ScrollDirection {
189 Up,
190 Down,
191 }
192
193 impl ScrollDirection {
194 fn sign(self) -> i32 {
195 match self {
196 ScrollDirection::Up => -1,
197 ScrollDirection::Down => 1,
198 }
199 }
200 }
201
202 /// Stateful tracker for mouse scroll accumulation.
203 #[derive(Debug, Default)]
204 pub struct MouseScrollState {
205 last_event_at: Option<Instant>,
206 pending_lines: i32,
207 }
208
209 /// A computed scroll delta from user input.
210 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
211 pub struct ScrollUpdate {
212 pub delta_lines: i32,
213 }
214
215 impl MouseScrollState {
216 /// Create a new scroll state tracker.
217 #[must_use]
218 pub fn new() -> Self {
219 Self::default()
220 }
221
222 /// Process a scroll event and return the resulting delta.
223 pub fn on_scroll(&mut self, direction: ScrollDirection) -> ScrollUpdate {
224 let now = Instant::now();
225 let is_trackpad = self
226 .last_event_at
227 .is_some_and(|last| now.duration_since(last) < Duration::from_millis(35));
228 self.last_event_at = Some(now);
229
230 let lines_per_tick = if is_trackpad { 1 } else { 3 };
231 self.pending_lines += direction.sign() * lines_per_tick;
232
233 let delta = self.pending_lines;
234 self.pending_lines = 0;
235 ScrollUpdate { delta_lines: delta }
236 }
237 }
238
239 #[cfg(test)]
240 mod tests {
241 use super::*;
242
243 fn cell_line(cell_index: usize, line_in_cell: usize) -> TranscriptLineMeta {
244 TranscriptLineMeta::CellLine {
245 cell_index,
246 line_in_cell,
247 }
248 }
249
250 /// Build a synthetic line-meta array for a transcript with `cell_count`
251 /// cells, each `lines_per_cell` lines tall, separated by spacers.
252 fn synth_line_meta(cell_count: usize, lines_per_cell: usize) -> Vec<TranscriptLineMeta> {
253 let mut meta = Vec::new();
254 for cell in 0..cell_count {
255 for line in 0..lines_per_cell {
256 meta.push(cell_line(cell, line));
257 }
258 if cell + 1 < cell_count {
259 meta.push(TranscriptLineMeta::Spacer);
260 }
261 }
262 meta
263 }
264
265 /// Default state follows the live tail. Resolving against any
266 /// `max_start` returns `max_start` and the canonical tail state.
267 #[test]
268 fn default_state_is_tail() {
269 let state = TranscriptScroll::default();
270 assert!(state.is_at_tail());
271 let meta = synth_line_meta(5, 3);
272 let max_start = 6;
273 let (resolved, top) = state.resolve_top(&meta, max_start);
274 assert!(resolved.is_at_tail());
275 assert_eq!(top, max_start);
276 }
277
278 /// A pinned offset below `max_start` resolves to itself unchanged.
279 /// (Originally: "anchor cell still exists" — same intent: scroll
280 /// position is preserved when it is still valid.)
281 #[test]
282 fn resolve_top_keeps_position_when_offset_in_range() {
283 let meta = synth_line_meta(5, 3); // 19 entries
284 let max_start = meta.len().saturating_sub(8);
285 let state = TranscriptScroll::at_line(9);
286 let (resolved, top) = state.resolve_top(&meta, max_start);
287 assert_eq!(resolved, TranscriptScroll::at_line(9));
288 assert_eq!(top, 9);
289 }
290
291 /// Regression for issue #56: when a content rewrite shrinks the
292 /// transcript so the user's offset is past the new `max_start`, we
293 /// clamp to the new max — we must NOT teleport to the top, and we
294 /// must NOT silently lose the position by sending the user to the
295 /// raw bottom of pre-rewrite content. Snapping to the tail is the
296 /// correct behaviour because the user's intended position no longer
297 /// has any content under it.
298 #[test]
299 fn resolve_top_clamps_when_offset_past_max_start() {
300 let meta = synth_line_meta(3, 2); // 8 entries (cells 0..3, 2 lines + 2 spacers)
301 let max_start = meta.len().saturating_sub(4);
302 // User had scrolled to a line that no longer exists post-rewrite.
303 let state = TranscriptScroll::at_line(15);
304 let (resolved, top) = state.resolve_top(&meta, max_start);
305 // Past max_start collapses to tail (which is the right answer:
306 // there is no content beyond max_start to show).
307 assert!(resolved.is_at_tail());
308 assert_eq!(top, max_start);
309 }
310
311 /// Regression for the new bug we are guarding against in this
312 /// refactor: scrolling up to mid-transcript, having the content
313 /// rewrite under us, and then drawing again must preserve the
314 /// offset (clamped if needed) and NOT teleport to top or to bottom
315 /// when the offset is still in-range.
316 #[test]
317 fn resolve_top_preserves_midway_offset_after_content_rewrite() {
318 // Pre-rewrite transcript: 10 cells × 3 lines + 9 spacers = 39 lines.
319 let pre = synth_line_meta(10, 3);
320 let visible = 8;
321 let pre_max_start = pre.len().saturating_sub(visible);
322
323 // User scrolls up to a midway line (line 12).
324 let state = TranscriptScroll::at_line(12);
325 let (state, top_before) = state.resolve_top(&pre, pre_max_start);
326 assert_eq!(top_before, 12);
327 assert_eq!(state, TranscriptScroll::at_line(12));
328
329 // Content rewrite: cell 4 expanded by two lines (e.g. inline
330 // RLM `repl` block became Thinking + Text). Total grows.
331 let mut post = pre.clone();
332 post.insert(13, cell_line(4, 3));
333 post.insert(14, cell_line(4, 4));
334 let post_max_start = post.len().saturating_sub(visible);
335 let (state2, top_after) = state.resolve_top(&post, post_max_start);
336 // Critical: still at line 12, not pulled to bottom or top.
337 assert_eq!(state2, TranscriptScroll::at_line(12));
338 assert_eq!(top_after, 12);
339
340 // Content rewrite shrunk transcript below the offset.
341 let post_shrunk = synth_line_meta(3, 3); // 11 lines total
342 let shrunk_max_start = post_shrunk.len().saturating_sub(visible);
343 let (state3, top_shrunk) = state.resolve_top(&post_shrunk, shrunk_max_start);
344 // Offset 12 > 11; we clamp to tail (no content beyond max_start).
345 assert!(state3.is_at_tail());
346 assert_eq!(top_shrunk, shrunk_max_start);
347 }
348
349 /// `scrolled_by` from a stale offset: pressing Up should still move
350 /// the user up, not lock them at the bottom. The flat-offset model
351 /// makes this trivial — the offset is simply clamped to `max_start`
352 /// before applying the delta.
353 #[test]
354 fn scrolled_by_does_not_teleport_on_stale_offset() {
355 let meta = synth_line_meta(3, 2); // 8 entries
356 let visible = 4;
357 let max_start = meta.len().saturating_sub(visible);
358 // User had scrolled past the new end of transcript.
359 let stale = TranscriptScroll::at_line(20);
360 let new_state = stale.scrolled_by(-1, &meta, visible);
361 // Either ends up Scrolled near the bottom (max_start - 1) or
362 // already at tail if max_start was 0.
363 if meta.len() > visible {
364 // Should be at max_start - 1 = 3.
365 assert_eq!(new_state, TranscriptScroll::at_line(max_start - 1));
366 }
367 }
368
369 /// When the transcript fits entirely in the viewport, scrolled_by
370 /// always collapses to tail.
371 #[test]
372 fn scrolled_by_collapses_to_bottom_when_view_fits() {
373 let meta = synth_line_meta(2, 2);
374 let visible = meta.len() + 5;
375 let state = TranscriptScroll::at_line(0);
376 let new_state = state.scrolled_by(-1, &meta, visible);
377 assert!(new_state.is_at_tail());
378 }
379
380 /// `scrolled_by` from tail with positive delta stays at tail (we
381 /// can't scroll past the bottom).
382 #[test]
383 fn scrolled_by_from_tail_down_stays_at_tail() {
384 let meta = synth_line_meta(5, 3);
385 let visible = 6;
386 let state = TranscriptScroll::to_bottom();
387 let new_state = state.scrolled_by(5, &meta, visible);
388 assert!(new_state.is_at_tail());
389 }
390
391 /// `scrolled_by` from tail with negative delta moves up by |delta|
392 /// from `max_start`.
393 #[test]
394 fn scrolled_by_from_tail_up_walks_back_from_max_start() {
395 let meta = synth_line_meta(5, 3); // 19 entries
396 let visible = 6;
397 let max_start = meta.len().saturating_sub(visible);
398 let state = TranscriptScroll::to_bottom();
399 let new_state = state.scrolled_by(-3, &meta, visible);
400 assert_eq!(new_state, TranscriptScroll::at_line(max_start - 3));
401 }
402
403 /// `anchor_for` clamps the requested start into the meta range and
404 /// produces a pinned state.
405 #[test]
406 fn anchor_for_clamps_start_into_range() {
407 let meta = synth_line_meta(4, 1);
408 let anchor = TranscriptScroll::anchor_for(&meta, 0).expect("non-empty");
409 assert_eq!(anchor, TranscriptScroll::at_line(0));
410
411 let anchor = TranscriptScroll::anchor_for(&meta, 1_000_000).expect("non-empty");
412 assert_eq!(
413 anchor,
414 TranscriptScroll::at_line(meta.len().saturating_sub(1))
415 );
416 }
417
418 /// Empty `line_meta` returns `None` so callers can fall back to
419 /// [`TranscriptScroll::to_bottom`].
420 #[test]
421 fn anchor_for_empty_returns_none() {
422 let meta: Vec<TranscriptLineMeta> = Vec::new();
423 assert!(TranscriptScroll::anchor_for(&meta, 0).is_none());
424 }
425
426 /// Tail state resolves to `max_start` regardless of the `line_meta`
427 /// contents.
428 #[test]
429 fn to_bottom_resolves_to_max_start() {
430 let meta = synth_line_meta(5, 2);
431 let max_start = 7;
432 let (state, top) = TranscriptScroll::to_bottom().resolve_top(&meta, max_start);
433 assert!(state.is_at_tail());
434 assert_eq!(top, max_start);
435 }
436 }
437
437 lines RUST