use std::time::{Duration, Instant};
const DEFAULT_FLUSH_GAP: Duration = Duration::from_millis(80);
const HISTORY_CAPACITY: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Up,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputDevice {
Wheel,
Trackpad,
}
pub fn acceleration_band(median_interval_ms: u64) -> f32 {
if median_interval_ms < 8 {
2.5
} else if median_interval_ms < 20 {
1.6
} else {
1.0
}
}
pub fn per_flush_cap(viewport_height: u16) -> u16 {
viewport_height / 2
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalKind {
AppleTerminal,
Ghostty,
ITerm2,
VSCode,
Multiplexer, Unknown,
}
impl TerminalKind {
pub fn ept(self) -> u8 {
if let Ok(s) = std::env::var("OXI_SCROLL_EPT")
&& let Ok(n) = s.parse::<u8>()
&& (1..=10).contains(&n)
{
return n;
}
match self {
TerminalKind::AppleTerminal | TerminalKind::Ghostty => 3,
TerminalKind::ITerm2
| TerminalKind::Multiplexer
| TerminalKind::VSCode
| TerminalKind::Unknown => 1,
}
}
}
pub fn detect_terminal() -> TerminalKind {
if std::env::var("TMUX").is_ok()
|| std::env::var("STY").is_ok()
|| std::env::var("ZELLIJ").is_ok()
{
return TerminalKind::Multiplexer;
}
match std::env::var("TERM_PROGRAM").as_deref() {
Ok("Apple_Terminal") => TerminalKind::AppleTerminal,
Ok("ghostty") => TerminalKind::Ghostty,
Ok("iTerm.app") => TerminalKind::ITerm2,
Ok("vscode") => TerminalKind::VSCode,
_ => TerminalKind::Unknown,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NormalizedScroll {
pub delta_lines: i32,
pub direction: ScrollDirection,
}
#[derive(Debug, Clone, Copy)]
struct PendingEvent {
direction: ScrollDirection,
at: Instant,
}
#[derive(Debug)]
pub struct ScrollNormalizer {
terminal: TerminalKind,
flush_gap: Duration,
history: Vec<PendingEvent>,
current_stream: Option<PendingEvent>,
}
impl ScrollNormalizer {
pub fn with_terminal(terminal: TerminalKind) -> Self {
let flush_gap = std::env::var("OXI_SCROLL_FLUSH_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(DEFAULT_FLUSH_GAP);
Self {
terminal,
flush_gap,
history: Vec::with_capacity(HISTORY_CAPACITY),
current_stream: None,
}
}
pub fn new() -> Self {
Self::with_terminal(detect_terminal())
}
pub fn push(&mut self, direction: ScrollDirection) -> Option<NormalizedScroll> {
let now = Instant::now();
let is_new_stream = match self.current_stream {
None => true,
Some(stream) => {
stream.direction != direction || now.duration_since(stream.at) > self.flush_gap
}
};
if is_new_stream {
let flushed = self.flush_internal();
self.current_stream = Some(PendingEvent { direction, at: now });
self.push_history(PendingEvent { direction, at: now });
flushed
} else {
self.current_stream = Some(PendingEvent { direction, at: now });
self.push_history(PendingEvent { direction, at: now });
None
}
}
pub fn flush(&mut self) -> Option<NormalizedScroll> {
self.flush_internal()
}
fn flush_internal(&mut self) -> Option<NormalizedScroll> {
let stream = self.current_stream.take()?;
let events_in_stream: usize = self
.history
.iter()
.rev()
.take_while(|e| e.direction == stream.direction && e.at <= stream.at)
.count();
let ept = self.terminal.ept() as i32;
let is_gap_flush = Instant::now().duration_since(stream.at) > self.flush_gap;
if (events_in_stream as i32) < ept && !is_gap_flush {
self.current_stream = Some(stream);
return None;
}
let notches = ((events_in_stream as i32) + ept - 1) / ept;
let lines_per_notch = 3;
let signed = match stream.direction {
ScrollDirection::Up => -(notches * lines_per_notch),
ScrollDirection::Down => notches * lines_per_notch,
};
Some(NormalizedScroll {
delta_lines: signed,
direction: stream.direction,
})
}
pub fn detect_device(&self) -> InputDevice {
if self.history.len() < 4 {
return InputDevice::Wheel;
}
let mut intervals: Vec<u128> = Vec::with_capacity(self.history.len() - 1);
for pair in self.history.windows(2) {
let dt = pair[1].at.duration_since(pair[0].at).as_millis();
intervals.push(dt);
}
intervals.sort_unstable();
let median = intervals[intervals.len() / 2];
if median < 12 {
InputDevice::Trackpad
} else {
InputDevice::Wheel
}
}
pub fn cap_delta(delta: i32, viewport_height: u16) -> i32 {
let cap = (viewport_height / 2).max(6) as i32;
delta.clamp(-cap, cap)
}
fn push_history(&mut self, event: PendingEvent) {
if self.history.len() >= HISTORY_CAPACITY {
self.history.remove(0);
}
self.history.push(event);
}
pub fn history_len(&self) -> usize {
self.history.len()
}
pub fn terminal(&self) -> TerminalKind {
self.terminal
}
pub fn reset(&mut self) {
self.history.clear();
self.current_stream = None;
}
}
impl Default for ScrollNormalizer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
#[test]
fn ept_apple_terminal_is_3() {
assert_eq!(TerminalKind::AppleTerminal.ept(), 3);
}
#[test]
fn ept_ghostty_is_3() {
assert_eq!(TerminalKind::Ghostty.ept(), 3);
}
#[test]
fn ept_iterm2_is_1() {
assert_eq!(TerminalKind::ITerm2.ept(), 1);
}
#[test]
fn ept_vscode_is_1() {
assert_eq!(TerminalKind::VSCode.ept(), 1);
}
#[test]
fn detect_term_program_ghostty() {
with_env_lock(|| {
unsafe {
std::env::remove_var("TMUX");
std::env::remove_var("STY");
std::env::remove_var("ZELLIJ");
std::env::set_var("TERM_PROGRAM", "ghostty");
}
assert_eq!(detect_terminal(), TerminalKind::Ghostty);
unsafe {
std::env::remove_var("TERM_PROGRAM");
}
});
}
#[test]
fn ept_unknown_is_1() {
assert_eq!(TerminalKind::Unknown.ept(), 1);
}
#[test]
fn detect_tmux_returns_multiplexer() {
with_env_lock(|| {
unsafe {
std::env::set_var("TMUX", "/tmp/tmux-1000/default,12345,0");
std::env::remove_var("STY");
std::env::remove_var("ZELLIJ");
std::env::remove_var("TERM_PROGRAM");
}
assert_eq!(detect_terminal(), TerminalKind::Multiplexer);
});
}
#[test]
fn detect_sty_returns_multiplexer() {
with_env_lock(|| {
unsafe {
std::env::remove_var("TMUX");
std::env::set_var("STY", "12345.pts-0.host");
std::env::remove_var("ZELLIJ");
}
assert_eq!(detect_terminal(), TerminalKind::Multiplexer);
});
}
#[test]
fn detect_zellij_returns_multiplexer() {
with_env_lock(|| {
unsafe {
std::env::remove_var("TMUX");
std::env::remove_var("STY");
std::env::set_var("ZELLIJ", "0");
}
assert_eq!(detect_terminal(), TerminalKind::Multiplexer);
});
}
#[test]
fn detect_term_program_apple_terminal() {
with_env_lock(|| {
unsafe {
std::env::remove_var("TMUX");
std::env::remove_var("STY");
std::env::remove_var("ZELLIJ");
std::env::set_var("TERM_PROGRAM", "Apple_Terminal");
}
assert_eq!(detect_terminal(), TerminalKind::AppleTerminal);
});
}
#[test]
fn detect_unknown_when_no_env() {
with_env_lock(|| {
unsafe {
std::env::remove_var("TMUX");
std::env::remove_var("STY");
std::env::remove_var("ZELLIJ");
std::env::remove_var("TERM_PROGRAM");
}
assert_eq!(detect_terminal(), TerminalKind::Unknown);
});
}
#[test]
fn ept_env_override_takes_precedence() {
with_env_lock(|| {
unsafe {
std::env::set_var("OXI_SCROLL_EPT", "5");
}
assert_eq!(TerminalKind::AppleTerminal.ept(), 5);
unsafe {
std::env::remove_var("OXI_SCROLL_EPT");
}
});
}
#[test]
fn ept_env_override_invalid_falls_back_to_table() {
with_env_lock(|| {
unsafe {
std::env::set_var("OXI_SCROLL_EPT", "0"); }
assert_eq!(TerminalKind::AppleTerminal.ept(), 3);
unsafe {
std::env::set_var("OXI_SCROLL_EPT", "abc"); }
assert_eq!(TerminalKind::AppleTerminal.ept(), 3);
unsafe {
std::env::set_var("OXI_SCROLL_EPT", "100"); }
assert_eq!(TerminalKind::AppleTerminal.ept(), 3);
unsafe {
std::env::remove_var("OXI_SCROLL_EPT");
}
});
}
#[test]
fn single_event_produces_single_scroll() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
let r = norm.push(ScrollDirection::Down);
assert!(r.is_none());
let r = norm.flush().expect("flush should produce");
assert_eq!(r.delta_lines, 3);
assert_eq!(r.direction, ScrollDirection::Down);
}
#[test]
fn multiple_events_same_direction_combine_into_stream() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::AppleTerminal);
assert!(norm.push(ScrollDirection::Down).is_none());
assert!(norm.push(ScrollDirection::Down).is_none());
assert!(norm.push(ScrollDirection::Down).is_none());
let r = norm.flush().expect("flush should produce");
assert_eq!(r.delta_lines, 3);
}
#[test]
fn direction_change_starts_new_stream() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
norm.push(ScrollDirection::Down);
let r = norm.push(ScrollDirection::Up);
assert!(r.is_some(), "direction change should flush previous stream");
assert_eq!(r.unwrap().delta_lines, 3); let r2 = norm
.flush()
.expect("single iTerm2 event should produce delta");
assert_eq!(r2.delta_lines, -3); }
#[test]
fn flush_gap_separates_streams() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
norm.push(ScrollDirection::Down);
std::thread::sleep(Duration::from_millis(120));
let r = norm.push(ScrollDirection::Down);
assert!(r.is_some(), "long gap should flush previous stream");
}
#[test]
fn apple_terminal_one_notch_matches_iterm2_one_notch() {
let mut apple = ScrollNormalizer::with_terminal(TerminalKind::AppleTerminal);
apple.push(ScrollDirection::Down);
apple.push(ScrollDirection::Down);
apple.push(ScrollDirection::Down);
let apple_delta = apple.flush().unwrap().delta_lines;
let mut iterm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
iterm.push(ScrollDirection::Down);
let iterm_delta = iterm.flush().unwrap().delta_lines;
assert_eq!(apple_delta, iterm_delta);
}
#[test]
fn history_capped_at_16() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
for _ in 0..30 {
norm.push(ScrollDirection::Down);
}
assert_eq!(norm.history_len(), 16);
}
#[test]
fn reset_clears_state() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
norm.push(ScrollDirection::Down);
norm.reset();
assert_eq!(norm.history_len(), 0);
assert!(norm.flush().is_none());
}
#[test]
fn detect_device_returns_wheel_for_few_events() {
let norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
assert_eq!(norm.detect_device(), InputDevice::Wheel);
}
#[test]
fn acceleration_band_thresholds() {
assert_eq!(acceleration_band(5), 2.5); assert_eq!(acceleration_band(8), 1.6); assert_eq!(acceleration_band(15), 1.6); assert_eq!(acceleration_band(20), 1.0); assert_eq!(acceleration_band(100), 1.0); }
#[test]
fn cap_delta_caps_at_viewport_half() {
assert_eq!(ScrollNormalizer::cap_delta(50, 24), 12);
assert_eq!(ScrollNormalizer::cap_delta(-50, 24), -12);
assert_eq!(ScrollNormalizer::cap_delta(8, 24), 8); }
#[test]
fn cap_delta_min_floor_is_six() {
assert_eq!(ScrollNormalizer::cap_delta(100, 8), 6);
assert_eq!(ScrollNormalizer::cap_delta(-100, 4), -6); }
#[test]
fn cap_delta_handles_zero() {
assert_eq!(ScrollNormalizer::cap_delta(0, 20), 0);
}
#[test]
fn detect_device_trackpad_for_short_intervals() {
let mut norm = ScrollNormalizer::with_terminal(TerminalKind::ITerm2);
for _ in 0..8 {
norm.push(ScrollDirection::Down);
}
let device = norm.detect_device();
assert_eq!(device, InputDevice::Trackpad);
}
}