use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptLineMeta {
CellLine {
cell_index: usize,
line_in_cell: usize,
},
Spacer,
}
impl TranscriptLineMeta {
#[must_use]
pub fn cell_line(&self) -> Option<(usize, usize)> {
match *self {
TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
} => Some((cell_index, line_in_cell)),
TranscriptLineMeta::Spacer => None,
}
}
}
const TAIL_SENTINEL: usize = usize::MAX;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TranscriptScroll {
offset: usize,
}
impl Default for TranscriptScroll {
fn default() -> Self {
Self::to_bottom()
}
}
impl TranscriptScroll {
#[must_use]
pub const fn to_bottom() -> Self {
Self {
offset: TAIL_SENTINEL,
}
}
#[must_use]
pub const fn at_line(offset: usize) -> Self {
Self { offset }
}
#[must_use]
pub const fn is_at_tail(self) -> bool {
self.offset == TAIL_SENTINEL
}
#[must_use]
pub fn resolve_top(self, line_meta: &[TranscriptLineMeta], max_start: usize) -> (Self, usize) {
let _ = line_meta;
if self.offset == TAIL_SENTINEL {
return (Self::to_bottom(), max_start);
}
let top = self.offset.min(max_start);
if top >= max_start {
(Self::to_bottom(), max_start)
} else {
(Self::at_line(top), top)
}
}
#[must_use]
pub fn scrolled_by(
self,
delta_lines: i32,
line_meta: &[TranscriptLineMeta],
visible_lines: usize,
) -> Self {
if delta_lines == 0 {
return self;
}
let total_lines = line_meta.len();
if total_lines <= visible_lines {
return Self::to_bottom();
}
let max_start = total_lines.saturating_sub(visible_lines);
let current_top = if self.offset == TAIL_SENTINEL {
max_start
} else {
self.offset.min(max_start)
};
let new_top = if delta_lines < 0 {
current_top.saturating_sub(delta_lines.unsigned_abs() as usize)
} else {
let delta = usize::try_from(delta_lines).unwrap_or(usize::MAX);
current_top.saturating_add(delta).min(max_start)
};
if new_top >= max_start {
Self::to_bottom()
} else {
Self::at_line(new_top)
}
}
#[must_use]
pub fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option<Self> {
if line_meta.is_empty() {
return None;
}
let clamped = start.min(line_meta.len().saturating_sub(1));
Some(Self::at_line(clamped))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Up,
Down,
}
impl ScrollDirection {
fn sign(self) -> i32 {
match self {
ScrollDirection::Up => -1,
ScrollDirection::Down => 1,
}
}
}
#[derive(Debug, Default)]
pub struct MouseScrollState {
last_event_at: Option<Instant>,
pending_lines: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollUpdate {
pub delta_lines: i32,
}
impl MouseScrollState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn on_scroll(&mut self, direction: ScrollDirection) -> ScrollUpdate {
let now = Instant::now();
let is_trackpad = self
.last_event_at
.is_some_and(|last| now.duration_since(last) < Duration::from_millis(35));
self.last_event_at = Some(now);
let lines_per_tick = if is_trackpad { 1 } else { 3 };
self.pending_lines += direction.sign() * lines_per_tick;
let delta = self.pending_lines;
self.pending_lines = 0;
ScrollUpdate { delta_lines: delta }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cell_line(cell_index: usize, line_in_cell: usize) -> TranscriptLineMeta {
TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
}
}
fn synth_line_meta(cell_count: usize, lines_per_cell: usize) -> Vec<TranscriptLineMeta> {
let mut meta = Vec::new();
for cell in 0..cell_count {
for line in 0..lines_per_cell {
meta.push(cell_line(cell, line));
}
if cell + 1 < cell_count {
meta.push(TranscriptLineMeta::Spacer);
}
}
meta
}
#[test]
fn default_state_is_tail() {
let state = TranscriptScroll::default();
assert!(state.is_at_tail());
let meta = synth_line_meta(5, 3);
let max_start = 6;
let (resolved, top) = state.resolve_top(&meta, max_start);
assert!(resolved.is_at_tail());
assert_eq!(top, max_start);
}
#[test]
fn resolve_top_keeps_position_when_offset_in_range() {
let meta = synth_line_meta(5, 3); let max_start = meta.len().saturating_sub(8);
let state = TranscriptScroll::at_line(9);
let (resolved, top) = state.resolve_top(&meta, max_start);
assert_eq!(resolved, TranscriptScroll::at_line(9));
assert_eq!(top, 9);
}
#[test]
fn resolve_top_clamps_when_offset_past_max_start() {
let meta = synth_line_meta(3, 2); let max_start = meta.len().saturating_sub(4);
let state = TranscriptScroll::at_line(15);
let (resolved, top) = state.resolve_top(&meta, max_start);
assert!(resolved.is_at_tail());
assert_eq!(top, max_start);
}
#[test]
fn resolve_top_preserves_midway_offset_after_content_rewrite() {
let pre = synth_line_meta(10, 3);
let visible = 8;
let pre_max_start = pre.len().saturating_sub(visible);
let state = TranscriptScroll::at_line(12);
let (state, top_before) = state.resolve_top(&pre, pre_max_start);
assert_eq!(top_before, 12);
assert_eq!(state, TranscriptScroll::at_line(12));
let mut post = pre.clone();
post.insert(13, cell_line(4, 3));
post.insert(14, cell_line(4, 4));
let post_max_start = post.len().saturating_sub(visible);
let (state2, top_after) = state.resolve_top(&post, post_max_start);
assert_eq!(state2, TranscriptScroll::at_line(12));
assert_eq!(top_after, 12);
let post_shrunk = synth_line_meta(3, 3); let shrunk_max_start = post_shrunk.len().saturating_sub(visible);
let (state3, top_shrunk) = state.resolve_top(&post_shrunk, shrunk_max_start);
assert!(state3.is_at_tail());
assert_eq!(top_shrunk, shrunk_max_start);
}
#[test]
fn scrolled_by_does_not_teleport_on_stale_offset() {
let meta = synth_line_meta(3, 2); let visible = 4;
let max_start = meta.len().saturating_sub(visible);
let stale = TranscriptScroll::at_line(20);
let new_state = stale.scrolled_by(-1, &meta, visible);
if meta.len() > visible {
assert_eq!(new_state, TranscriptScroll::at_line(max_start - 1));
}
}
#[test]
fn scrolled_by_collapses_to_bottom_when_view_fits() {
let meta = synth_line_meta(2, 2);
let visible = meta.len() + 5;
let state = TranscriptScroll::at_line(0);
let new_state = state.scrolled_by(-1, &meta, visible);
assert!(new_state.is_at_tail());
}
#[test]
fn scrolled_by_from_tail_down_stays_at_tail() {
let meta = synth_line_meta(5, 3);
let visible = 6;
let state = TranscriptScroll::to_bottom();
let new_state = state.scrolled_by(5, &meta, visible);
assert!(new_state.is_at_tail());
}
#[test]
fn scrolled_by_from_tail_up_walks_back_from_max_start() {
let meta = synth_line_meta(5, 3); let visible = 6;
let max_start = meta.len().saturating_sub(visible);
let state = TranscriptScroll::to_bottom();
let new_state = state.scrolled_by(-3, &meta, visible);
assert_eq!(new_state, TranscriptScroll::at_line(max_start - 3));
}
#[test]
fn anchor_for_clamps_start_into_range() {
let meta = synth_line_meta(4, 1);
let anchor = TranscriptScroll::anchor_for(&meta, 0).expect("non-empty");
assert_eq!(anchor, TranscriptScroll::at_line(0));
let anchor = TranscriptScroll::anchor_for(&meta, 1_000_000).expect("non-empty");
assert_eq!(
anchor,
TranscriptScroll::at_line(meta.len().saturating_sub(1))
);
}
#[test]
fn anchor_for_empty_returns_none() {
let meta: Vec<TranscriptLineMeta> = Vec::new();
assert!(TranscriptScroll::anchor_for(&meta, 0).is_none());
}
#[test]
fn to_bottom_resolves_to_max_start() {
let meta = synth_line_meta(5, 2);
let max_start = 7;
let (state, top) = TranscriptScroll::to_bottom().resolve_top(&meta, max_start);
assert!(state.is_at_tail());
assert_eq!(top, max_start);
}
}