返回 CodeWhale
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 use crate::tui::ui_text::CopyLineSeparator;
21
22 const TRACKPAD_EVENT_WINDOW: Duration = Duration::from_millis(35);
23 const WHEEL_LINES_PER_TICK: i32 = 3;
24 const TRACKPAD_BASE_LINES_PER_TICK: i32 = 1;
25 const TRACKPAD_MID_LINES_PER_TICK: i32 = 2;
26 const TRACKPAD_MAX_LINES_PER_TICK: i32 = 3;
27
28 // === Transcript Line Metadata ===
29
30 /// Metadata describing how rendered transcript lines map to history cells.
31 ///
32 /// The scroll state itself does not consult this — it only stores a flat
33 /// line offset — but other render-time helpers (selection painting,
34 /// send-flash, jump-to-tool, scrollbar percent) still need the
35 /// line→cell mapping the cache exposes.
36 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37 pub enum TranscriptLineMeta {
38 CellLine {
39 cell_index: usize,
40 line_in_cell: usize,
41 copy_prefix_width: usize,
42 copy_separator_after: CopyLineSeparator,
43 },
44 /// A block separator row inserted between two cells. Usually empty, but
45 /// separators inside a tool-card rail group carry the rail glyph so the
46 /// card box survives the gap — hence a copy prefix to strip.
47 Spacer { copy_prefix_width: usize },
48 }
49
50 impl TranscriptLineMeta {
51 /// Return cell/line indices if this entry is a cell line.
52 #[must_use]
53 pub fn cell_line(&self) -> Option<(usize, usize)> {
54 match *self {
55 TranscriptLineMeta::CellLine {
56 cell_index,
57 line_in_cell,
58 ..
59 } => Some((cell_index, line_in_cell)),
60 TranscriptLineMeta::Spacer { .. } => None,
61 }
62 }
63
64 #[must_use]
65 pub fn copy_separator_after(&self) -> CopyLineSeparator {
66 match *self {
67 TranscriptLineMeta::CellLine {
68 copy_separator_after,
69 ..
70 } => copy_separator_after,
71 TranscriptLineMeta::Spacer { .. } => CopyLineSeparator::Newline,
72 }
73 }
74
75 #[must_use]
76 pub fn copy_prefix_width(&self) -> usize {
77 match *self {
78 TranscriptLineMeta::CellLine {
79 copy_prefix_width, ..
80 } => copy_prefix_width,
81 TranscriptLineMeta::Spacer { copy_prefix_width } => copy_prefix_width,
82 }
83 }
84 }
85
86 // === Transcript Scroll State ===
87
88 /// Sentinel offset meaning "stuck to live tail" — the renderer translates
89 /// this to `max_start` at draw time, so newly appended lines pull the view
90 /// down with them.
91 const TAIL_SENTINEL: usize = usize::MAX;
92
93 /// Flat line-offset scroll state for the transcript view.
94 ///
95 /// Stores the index of the top visible line into the cache's `line_meta`
96 /// buffer, or [`TAIL_SENTINEL`] (`usize::MAX`) to mean "stuck to bottom."
97 /// The renderer resolves the sentinel against the current line count and
98 /// viewport height every frame, so content rewrites simply clamp the
99 /// user's offset rather than triggering anchor recovery heuristics.
100 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 pub struct TranscriptScroll {
102 offset: usize,
103 }
104
105 impl Default for TranscriptScroll {
106 /// Default state is "stuck to live tail" — matches the historical
107 /// `TranscriptScroll::ToBottom` behaviour callers already depend on.
108 fn default() -> Self {
109 Self::to_bottom()
110 }
111 }
112
113 impl TranscriptScroll {
114 /// State that follows the live tail (default).
115 #[must_use]
116 pub const fn to_bottom() -> Self {
117 Self {
118 offset: TAIL_SENTINEL,
119 }
120 }
121
122 /// State pinned to a specific line index.
123 #[must_use]
124 pub const fn at_line(offset: usize) -> Self {
125 Self { offset }
126 }
127
128 /// Returns true when the view is following the live tail.
129 #[must_use]
130 pub const fn is_at_tail(self) -> bool {
131 self.offset == TAIL_SENTINEL
132 }
133
134 /// Resolve the scroll state to a concrete top line index.
135 ///
136 /// `max_start` is `total_lines.saturating_sub(visible_lines)`. The
137 /// returned `Self` is the canonicalized state — if the resolved top
138 /// reached the tail (or the transcript fits in one screen) we collapse
139 /// to [`TranscriptScroll::to_bottom`], so the caller can treat the
140 /// returned state as authoritative.
141 ///
142 /// `line_meta` is accepted for API compatibility with the previous
143 /// cell-anchored implementation. It is unused here because the flat
144 /// offset model needs no cell-index lookup; we just clamp.
145 #[must_use]
146 pub fn resolve_top(self, line_meta: &[TranscriptLineMeta], max_start: usize) -> (Self, usize) {
147 let _ = line_meta;
148 if self.offset == TAIL_SENTINEL {
149 return (Self::to_bottom(), max_start);
150 }
151 let top = self.offset.min(max_start);
152 if top >= max_start {
153 (Self::to_bottom(), max_start)
154 } else {
155 (Self::at_line(top), top)
156 }
157 }
158
159 /// Apply a scroll delta and return the updated state.
160 ///
161 /// `delta_lines` is signed: negative scrolls up (toward the start),
162 /// positive scrolls down (toward the tail). When the resolved offset
163 /// hits `max_start` we snap to [`TranscriptScroll::to_bottom`] so
164 /// subsequent appended content pulls the view along.
165 ///
166 /// `line_meta` is accepted for API compatibility; only its length is
167 /// consulted. `visible_lines` controls the page size for clamping.
168 #[must_use]
169 pub fn scrolled_by(
170 self,
171 delta_lines: i32,
172 line_meta: &[TranscriptLineMeta],
173 visible_lines: usize,
174 ) -> Self {
175 if delta_lines == 0 {
176 return self;
177 }
178
179 let total_lines = line_meta.len();
180 if total_lines <= visible_lines {
181 // Whole transcript fits; only "tail" is meaningful.
182 return Self::to_bottom();
183 }
184
185 let max_start = total_lines.saturating_sub(visible_lines);
186 let current_top = if self.offset == TAIL_SENTINEL {
187 max_start
188 } else {
189 self.offset.min(max_start)
190 };
191
192 let new_top = if delta_lines < 0 {
193 current_top.saturating_sub(delta_lines.unsigned_abs() as usize)
194 } else {
195 let delta = usize::try_from(delta_lines).unwrap_or(usize::MAX);
196 current_top.saturating_add(delta).min(max_start)
197 };
198
199 if new_top >= max_start {
200 Self::to_bottom()
201 } else {
202 Self::at_line(new_top)
203 }
204 }
205
206 /// Pin the scroll state to a specific line index in the rendered
207 /// transcript (saturating to the meta buffer length).
208 ///
209 /// Returns `None` if `line_meta` is empty (caller should default to
210 /// [`TranscriptScroll::to_bottom`] in that case).
211 #[must_use]
212 pub fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option<Self> {
213 if line_meta.is_empty() {
214 return None;
215 }
216 let clamped = start.min(line_meta.len().saturating_sub(1));
217 Some(Self::at_line(clamped))
218 }
219 }
220
221 /// Direction for mouse scroll input.
222 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
223 pub enum ScrollDirection {
224 Up,
225 Down,
226 }
227
228 impl ScrollDirection {
229 fn sign(self) -> i32 {
230 match self {
231 ScrollDirection::Up => -1,
232 ScrollDirection::Down => 1,
233 }
234 }
235 }
236
237 /// Stateful tracker for mouse scroll accumulation.
238 #[derive(Debug, Default)]
239 pub struct MouseScrollState {
240 last_event_at: Option<Instant>,
241 last_direction: Option<ScrollDirection>,
242 rapid_same_direction_ticks: u8,
243 }
244
245 /// A computed scroll delta from user input.
246 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
247 pub struct ScrollUpdate {
248 pub delta_lines: i32,
249 }
250
251 impl MouseScrollState {
252 /// Create a new scroll state tracker.
253 #[must_use]
254 pub fn new() -> Self {
255 Self::default()
256 }
257
258 /// Process a scroll event and return the resulting delta.
259 pub fn on_scroll(&mut self, direction: ScrollDirection) -> ScrollUpdate {
260 let now = Instant::now();
261 self.on_scroll_at(direction, now)
262 }
263
264 fn on_scroll_at(&mut self, direction: ScrollDirection, now: Instant) -> ScrollUpdate {
265 let is_trackpad = self
266 .last_event_at
267 .is_some_and(|last| now.saturating_duration_since(last) < TRACKPAD_EVENT_WINDOW);
268 let same_direction = self.last_direction == Some(direction);
269
270 self.last_event_at = Some(now);
271 self.last_direction = Some(direction);
272
273 let lines_per_tick = if is_trackpad {
274 if same_direction {
275 self.rapid_same_direction_ticks = self.rapid_same_direction_ticks.saturating_add(1);
276 } else {
277 self.rapid_same_direction_ticks = 1;
278 }
279 match self.rapid_same_direction_ticks {
280 0..=2 => TRACKPAD_BASE_LINES_PER_TICK,
281 3..=5 => TRACKPAD_MID_LINES_PER_TICK,
282 _ => TRACKPAD_MAX_LINES_PER_TICK,
283 }
284 } else {
285 self.rapid_same_direction_ticks = 0;
286 WHEEL_LINES_PER_TICK
287 };
288
289 ScrollUpdate {
290 delta_lines: direction.sign() * lines_per_tick,
291 }
292 }
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use super::*;
298
299 fn cell_line(cell_index: usize, line_in_cell: usize) -> TranscriptLineMeta {
300 TranscriptLineMeta::CellLine {
301 cell_index,
302 line_in_cell,
303 copy_prefix_width: 0,
304 copy_separator_after: CopyLineSeparator::Newline,
305 }
306 }
307
308 /// Build a synthetic line-meta array for a transcript with `cell_count`
309 /// cells, each `lines_per_cell` lines tall, separated by spacers.
310 fn synth_line_meta(cell_count: usize, lines_per_cell: usize) -> Vec<TranscriptLineMeta> {
311 let mut meta = Vec::new();
312 for cell in 0..cell_count {
313 for line in 0..lines_per_cell {
314 meta.push(cell_line(cell, line));
315 }
316 if cell + 1 < cell_count {
317 meta.push(TranscriptLineMeta::Spacer {
318 copy_prefix_width: 0,
319 });
320 }
321 }
322 meta
323 }
324
325 /// Default state follows the live tail. Resolving against any
326 /// `max_start` returns `max_start` and the canonical tail state.
327 #[test]
328 fn default_state_is_tail() {
329 let state = TranscriptScroll::default();
330 assert!(state.is_at_tail());
331 let meta = synth_line_meta(5, 3);
332 let max_start = 6;
333 let (resolved, top) = state.resolve_top(&meta, max_start);
334 assert!(resolved.is_at_tail());
335 assert_eq!(top, max_start);
336 }
337
338 /// A pinned offset below `max_start` resolves to itself unchanged.
339 /// (Originally: "anchor cell still exists" — same intent: scroll
340 /// position is preserved when it is still valid.)
341 #[test]
342 fn resolve_top_keeps_position_when_offset_in_range() {
343 let meta = synth_line_meta(5, 3); // 19 entries
344 let max_start = meta.len().saturating_sub(8);
345 let state = TranscriptScroll::at_line(9);
346 let (resolved, top) = state.resolve_top(&meta, max_start);
347 assert_eq!(resolved, TranscriptScroll::at_line(9));
348 assert_eq!(top, 9);
349 }
350
351 /// Regression for issue #56: when a content rewrite shrinks the
352 /// transcript so the user's offset is past the new `max_start`, we
353 /// clamp to the new max — we must NOT teleport to the top, and we
354 /// must NOT silently lose the position by sending the user to the
355 /// raw bottom of pre-rewrite content. Snapping to the tail is the
356 /// correct behaviour because the user's intended position no longer
357 /// has any content under it.
358 #[test]
359 fn resolve_top_clamps_when_offset_past_max_start() {
360 let meta = synth_line_meta(3, 2); // 8 entries (cells 0..3, 2 lines + 2 spacers)
361 let max_start = meta.len().saturating_sub(4);
362 // User had scrolled to a line that no longer exists post-rewrite.
363 let state = TranscriptScroll::at_line(15);
364 let (resolved, top) = state.resolve_top(&meta, max_start);
365 // Past max_start collapses to tail (which is the right answer:
366 // there is no content beyond max_start to show).
367 assert!(resolved.is_at_tail());
368 assert_eq!(top, max_start);
369 }
370
371 /// Regression for the new bug we are guarding against in this
372 /// refactor: scrolling up to mid-transcript, having the content
373 /// rewrite under us, and then drawing again must preserve the
374 /// offset (clamped if needed) and NOT teleport to top or to bottom
375 /// when the offset is still in-range.
376 #[test]
377 fn resolve_top_preserves_midway_offset_after_content_rewrite() {
378 // Pre-rewrite transcript: 10 cells × 3 lines + 9 spacers = 39 lines.
379 let pre = synth_line_meta(10, 3);
380 let visible = 8;
381 let pre_max_start = pre.len().saturating_sub(visible);
382
383 // User scrolls up to a midway line (line 12).
384 let state = TranscriptScroll::at_line(12);
385 let (state, top_before) = state.resolve_top(&pre, pre_max_start);
386 assert_eq!(top_before, 12);
387 assert_eq!(state, TranscriptScroll::at_line(12));
388
389 // Content rewrite: cell 4 expanded by two lines (e.g. inline
390 // RLM `repl` block became Thinking + Text). Total grows.
391 let mut post = pre.clone();
392 post.insert(13, cell_line(4, 3));
393 post.insert(14, cell_line(4, 4));
394 let post_max_start = post.len().saturating_sub(visible);
395 let (state2, top_after) = state.resolve_top(&post, post_max_start);
396 // Critical: still at line 12, not pulled to bottom or top.
397 assert_eq!(state2, TranscriptScroll::at_line(12));
398 assert_eq!(top_after, 12);
399
400 // Content rewrite shrunk transcript below the offset.
401 let post_shrunk = synth_line_meta(3, 3); // 11 lines total
402 let shrunk_max_start = post_shrunk.len().saturating_sub(visible);
403 let (state3, top_shrunk) = state.resolve_top(&post_shrunk, shrunk_max_start);
404 // Offset 12 > 11; we clamp to tail (no content beyond max_start).
405 assert!(state3.is_at_tail());
406 assert_eq!(top_shrunk, shrunk_max_start);
407 }
408
409 /// `scrolled_by` from a stale offset: pressing Up should still move
410 /// the user up, not lock them at the bottom. The flat-offset model
411 /// makes this trivial — the offset is simply clamped to `max_start`
412 /// before applying the delta.
413 #[test]
414 fn scrolled_by_does_not_teleport_on_stale_offset() {
415 let meta = synth_line_meta(3, 2); // 8 entries
416 let visible = 4;
417 let max_start = meta.len().saturating_sub(visible);
418 // User had scrolled past the new end of transcript.
419 let stale = TranscriptScroll::at_line(20);
420 let new_state = stale.scrolled_by(-1, &meta, visible);
421 // Either ends up Scrolled near the bottom (max_start - 1) or
422 // already at tail if max_start was 0.
423 if meta.len() > visible {
424 // Should be at max_start - 1 = 3.
425 assert_eq!(new_state, TranscriptScroll::at_line(max_start - 1));
426 }
427 }
428
429 /// When the transcript fits entirely in the viewport, scrolled_by
430 /// always collapses to tail.
431 #[test]
432 fn scrolled_by_collapses_to_bottom_when_view_fits() {
433 let meta = synth_line_meta(2, 2);
434 let visible = meta.len() + 5;
435 let state = TranscriptScroll::at_line(0);
436 let new_state = state.scrolled_by(-1, &meta, visible);
437 assert!(new_state.is_at_tail());
438 }
439
440 /// `scrolled_by` from tail with positive delta stays at tail (we
441 /// can't scroll past the bottom).
442 #[test]
443 fn scrolled_by_from_tail_down_stays_at_tail() {
444 let meta = synth_line_meta(5, 3);
445 let visible = 6;
446 let state = TranscriptScroll::to_bottom();
447 let new_state = state.scrolled_by(5, &meta, visible);
448 assert!(new_state.is_at_tail());
449 }
450
451 /// `scrolled_by` from tail with negative delta moves up by |delta|
452 /// from `max_start`.
453 #[test]
454 fn scrolled_by_from_tail_up_walks_back_from_max_start() {
455 let meta = synth_line_meta(5, 3); // 19 entries
456 let visible = 6;
457 let max_start = meta.len().saturating_sub(visible);
458 let state = TranscriptScroll::to_bottom();
459 let new_state = state.scrolled_by(-3, &meta, visible);
460 assert_eq!(new_state, TranscriptScroll::at_line(max_start - 3));
461 }
462
463 /// `anchor_for` clamps the requested start into the meta range and
464 /// produces a pinned state.
465 #[test]
466 fn anchor_for_clamps_start_into_range() {
467 let meta = synth_line_meta(4, 1);
468 let anchor = TranscriptScroll::anchor_for(&meta, 0).expect("non-empty");
469 assert_eq!(anchor, TranscriptScroll::at_line(0));
470
471 let anchor = TranscriptScroll::anchor_for(&meta, 1_000_000).expect("non-empty");
472 assert_eq!(
473 anchor,
474 TranscriptScroll::at_line(meta.len().saturating_sub(1))
475 );
476 }
477
478 /// Empty `line_meta` returns `None` so callers can fall back to
479 /// [`TranscriptScroll::to_bottom`].
480 #[test]
481 fn anchor_for_empty_returns_none() {
482 let meta: Vec<TranscriptLineMeta> = Vec::new();
483 assert!(TranscriptScroll::anchor_for(&meta, 0).is_none());
484 }
485
486 /// Tail state resolves to `max_start` regardless of the `line_meta`
487 /// contents.
488 #[test]
489 fn to_bottom_resolves_to_max_start() {
490 let meta = synth_line_meta(5, 2);
491 let max_start = 7;
492 let (state, top) = TranscriptScroll::to_bottom().resolve_top(&meta, max_start);
493 assert!(state.is_at_tail());
494 assert_eq!(top, max_start);
495 }
496
497 #[test]
498 fn mouse_scroll_single_wheel_tick_moves_three_lines() {
499 let mut state = MouseScrollState::new();
500 let start = Instant::now();
501
502 assert_eq!(
503 state.on_scroll_at(ScrollDirection::Down, start).delta_lines,
504 3
505 );
506 assert_eq!(
507 state.on_scroll_at(ScrollDirection::Up, start).delta_lines,
508 -1,
509 "same timestamp is treated as a rapid precise input"
510 );
511 }
512
513 #[test]
514 fn mouse_scroll_rapid_same_direction_accelerates_but_caps() {
515 let mut state = MouseScrollState::new();
516 let start = Instant::now();
517
518 let deltas = [
519 state.on_scroll_at(ScrollDirection::Down, start).delta_lines,
520 state
521 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(10))
522 .delta_lines,
523 state
524 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(20))
525 .delta_lines,
526 state
527 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(30))
528 .delta_lines,
529 state
530 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(40))
531 .delta_lines,
532 state
533 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(50))
534 .delta_lines,
535 state
536 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(60))
537 .delta_lines,
538 state
539 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(70))
540 .delta_lines,
541 ];
542
543 assert_eq!(deltas, [3, 1, 1, 2, 2, 2, 3, 3]);
544 }
545
546 #[test]
547 fn mouse_scroll_direction_change_resets_acceleration() {
548 let mut state = MouseScrollState::new();
549 let start = Instant::now();
550
551 for step in 0..8 {
552 let _ = state.on_scroll_at(
553 ScrollDirection::Down,
554 start + Duration::from_millis(step * 10),
555 );
556 }
557
558 assert_eq!(
559 state
560 .on_scroll_at(ScrollDirection::Up, start + Duration::from_millis(90))
561 .delta_lines,
562 -1
563 );
564 }
565
566 #[test]
567 fn mouse_scroll_slow_gap_resets_to_wheel_tick() {
568 let mut state = MouseScrollState::new();
569 let start = Instant::now();
570
571 assert_eq!(
572 state.on_scroll_at(ScrollDirection::Down, start).delta_lines,
573 3
574 );
575 assert_eq!(
576 state
577 .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(100))
578 .delta_lines,
579 3
580 );
581 }
582 }
583
583 lines RUST