Files
codex/codex-rs/tui2/docs/scroll_input_model.md
Josh McKinney a40bb0af20 docs(tui2): document viewport/history architecture
- Add architecture/roadmap doc + running notes for the TUI2 transcript-owned
  viewport/history work (PR index, module map, gaps, roadmap).
- Add tester-facing guide for validation and triage (scroll, selection/copy,
  streaming reflow, overlays, exit printing, perf).
- Keep earlier design docs intact but add links to the new docs; align tables.

Docs in this change were automatically created by codex (gpt-5.2 xhigh).
2026-01-05 13:33:39 -08:00

30 KiB
Raw Blame History

TUI2 Scroll Input: Model and Implementation

Note: This doc is historical. For the current architecture/status/roadmap, see tui2/docs/tui2_viewport_history_architecture.md.

This is the single "scrolling doc of record" for TUI2.

It describes what we implemented, why it works, and what we tried before this approach. It also preserves the scroll-probe findings (see Appendix) that motivated the model.

Code reference: codex-rs/tui2/src/tui/scrolling/mouse.rs.

Goals and constraints

Goals:

  • Mouse wheel: scroll about 3 transcript lines per physical wheel tick regardless of terminal event density (classic feel).
  • Trackpad: remain higher fidelity, meaning small movements can accumulate fractionally and should not be forced into wheel behavior.
  • Work across terminals where a single wheel tick may produce 1, 3, 9, or more raw events.

Constraints:

  • Terminals typically encode both wheels and trackpads as the same "scroll up/down" mouse button events without a magnitude. We cannot reliably observe device type directly.
  • Timing alone is not a reliable discriminator (wheel and trackpad bursts overlap).

Current implementation (stream-based; data-driven)

TUI2 uses a stream model: scroll events are grouped into short streams separated by silence. Within a stream, we normalize by a per-terminal "events per tick" factor and then apply either wheel-like (fixed lines per tick) or trackpad-like (fractional) semantics.

1. Stream detection

  • A stream begins on the first scroll event.
  • A stream ends when the gap since the last event exceeds STREAM_GAP_MS or when direction flips.
  • Direction flips always close the current stream and start a new one, so we never blend "up" and "down" into a single accumulator.

This makes behavior stable across:

  • Dense bursts (Warp/Ghostty-style sub-ms intervals).
  • Sparse bursts (single events separated by tens or hundreds of ms).
  • Mixed wheel + trackpad input where direction changes quickly.

2. Normalization: events-per-tick

Different terminals emit different numbers of raw events per physical wheel notch. We normalize by converting raw events into tick-equivalents:

tick_equivalents = raw_events / events_per_tick

Per-terminal defaults come from the probe logs (Appendix), and users can override them.

Config key: tui.scroll_events_per_tick.

3. Wheel vs trackpad behavior (and why it is heuristic)

Because device type is not directly observable, the implementation provides a mode setting:

  • tui.scroll_mode = "auto" (default): infer wheel-like vs trackpad-like behavior per stream.
  • tui.scroll_mode = "wheel": always treat streams as wheel-like.
  • tui.scroll_mode = "trackpad": always treat streams as trackpad-like.

In auto mode:

  • Streams start trackpad-like (safer: avoids overshoot when we guess wrong).
  • Streams promote to wheel-like when the first tick-worth of events arrives quickly.
  • For 1-event-per-tick terminals, "first tick completion time" is not observable, so there is a conservative end-of-stream fallback for very small bursts.

This design assumes that auto classification is a best-effort heuristic and must be overridable.

4. Applying scroll: wheel-like streams

Wheel-like streams target the "classic feel" requirement.

  • Each raw event contributes tui.scroll_wheel_lines / events_per_tick lines.
  • Deltas flush immediately (not cadence-gated) so wheels feel snappy even on dense streams.
  • Wheel-like streams apply a minimum +/- 1 line when events were received but rounding would yield 0.

Defaults:

  • tui.scroll_wheel_lines = 3

5. Applying scroll: trackpad-like streams

Trackpad-like streams are designed for fidelity first.

  • Each raw event contributes tui.scroll_trackpad_lines / trackpad_events_per_tick lines.
  • Fractional remainder is carried across streams, so tiny gestures accumulate instead of being lost.
  • Trackpad deltas are cadence-gated to ~60 Hz (REDRAW_CADENCE_MS) to avoid redraw floods and to reduce "stop lag" / overshoot.
  • Trackpad streams intentionally do not apply a minimum +/- 1 line at stream end; if a gesture is small enough to round to 0, it should feel like "no movement", not a forced jump.

Dense wheel terminals (e.g. Ghostty/Warp) can emit trackpad streams with high event density. Using a wheel-derived events_per_tick = 9 for trackpad would make trackpads feel slow, so we use a capped divisor for trackpad normalization:

  • trackpad_events_per_tick = min(events_per_tick, 3)

Additionally, to keep small gestures precise while making large/fast swipes cover more content, trackpad-like streams apply bounded acceleration based on event count:

  • tui.scroll_trackpad_accel_events: how many events correspond to +1x multiplier.
  • tui.scroll_trackpad_accel_max: maximum multiplier.

6. Guard rails and axis handling

  • Horizontal scroll events are ignored for vertical scrolling.
  • Streams clamp event counts and accumulated line deltas to avoid floods.

Terminal defaults and per-terminal tuning

Defaults are keyed by TerminalName (terminal family), not exact version. Probe data is version-specific, so defaults should be revalidated as more logs arrive.

Events-per-tick defaults derived from wheel_single medians:

  • AppleTerminal: 3
  • WarpTerminal: 9
  • WezTerm: 1
  • Alacritty: 3
  • Ghostty: 3
  • Iterm2: 1
  • VsCode: 1
  • Kitty: 3
  • Unknown: 3

Note: probe logs measured Ghostty at ~9 events per tick, but we default to 3 because an upstream Ghostty change is expected to reduce wheel event density. Users can override with tui.scroll_events_per_tick.

Auto-mode wheel promotion thresholds can also be tuned per terminal if needed (see config below).

Configuration knobs (TUI2)

These are user-facing knobs in config.toml under [tui]:

In this repo, "tick" always refers to a physical mouse wheel notch. Trackpads do not have ticks, so trackpad settings are expressed in terms of "tick-equivalents" (raw events normalized to a common scale).

The core normalization formulas are:

  • Wheel-like streams:
    • lines_per_event = scroll_wheel_lines / scroll_events_per_tick
  • Trackpad-like streams:
    • lines_per_event = scroll_trackpad_lines / min(scroll_events_per_tick, 3)
    • (plus bounded acceleration from scroll_trackpad_accel_* and fractional carry across streams)

Keys:

  • scroll_events_per_tick (number):
    • Raw vertical scroll events per physical wheel notch in your terminal (normalization input).
    • Affects wheel-like scroll speed and auto-mode wheel promotion timing.
    • Trackpad-like mode uses min(..., 3) as the divisor so dense wheel ticks (e.g. 9 events per notch) do not make trackpads feel artificially slow.
  • scroll_wheel_lines (number):
    • Lines per physical wheel notch (default 3).
    • Change this if you want "classic" wheel scrolling to be more/less aggressive globally.
  • scroll_trackpad_lines (number):
    • Baseline trackpad sensitivity in trackpad-like mode (default 1).
    • Change this if your trackpad feels consistently too slow/fast for small motions.
  • scroll_trackpad_accel_events (number):
    • Trackpad acceleration tuning (default 30). Smaller values accelerate earlier.
    • Trackpad-like streams compute a multiplier:
      • multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)
    • The multiplier is applied to the trackpad streams computed line delta (including any carried fractional remainder).
  • scroll_trackpad_accel_max (number):
    • Trackpad acceleration cap (default 3). Set to 1 to effectively disable acceleration.
  • scroll_mode (auto | wheel | trackpad):
    • auto (default): infer wheel-like vs trackpad-like per stream.
    • wheel: always wheel-like (good for wheel-only setups; trackpads will feel jumpy).
    • trackpad: always trackpad-like (good if auto misclassifies; wheels may feel slow).
  • scroll_wheel_tick_detect_max_ms (number):
    • Auto-mode promotion threshold: how quickly the first tick-worth of events must arrive to consider the stream wheel-like.
    • If wheel feels slow in a dense-wheel terminal, increasing this is usually better than changing scroll_events_per_tick.
  • scroll_wheel_like_max_duration_ms (number):
    • Auto-mode fallback for 1-event-per-tick terminals (WezTerm/iTerm/VS Code).
    • If wheel feels like trackpad (too slow) in those terminals, increasing this can help.
  • scroll_invert (bool):
    • Invert direction after terminal detection; applies consistently to wheel and trackpad.

Previous approaches tried (and why they were replaced)

  1. Cadence-based inference (rolling inter-event thresholds)
  • Approach: infer wheel vs trackpad using inter-event timing thresholds (burst vs frame cadence vs slow), with terminal-specific tuning.
  • Problem: terminals differ more in event density and batching than in timing; timing overlaps heavily between wheel and trackpad. Small threshold changes had outsized, terminal-specific effects.
  1. Pure event-count or pure duration classification
  • Approach: classify wheel-like vs trackpad-like by event count <= N or duration <= M.
  • Problem: burst length overlaps heavily across devices/terminals; duration is more separable but still not strong enough to be authoritative.
  1. Why streams + normalization won
  • Streams give a stable unit ("what did the user do in one gesture?") that we can bound and reason about.
  • Normalization directly addresses the main cross-terminal source of variation: raw event density.
  • Classification remains heuristic, but is isolated and configurable.

Appendix A: Follow-up analysis (latest log per terminal; 2025-12-20)

This section is derived from a "latest log per terminal" subset analysis. The exact event count is not significant; it is included only as a note about which subset was used.

Key takeaways:

  • Burst length overlaps heavily between wheel and trackpad. Simple "event count <= N" classifiers perform poorly.
  • Burst span (duration) is more separable: wheel bursts typically complete in < ~180-200 ms, while trackpad bursts are often hundreds of milliseconds.
  • Conclusion: explicit wheel vs trackpad classification is inherently weak from these events; prefer a stream model, plus a small heuristic and a config override (tui.scroll_mode) for edge cases.

Data notes (latest per terminal label):

  • Logs used (one per terminal, by filename timestamp):
    • mouse_scroll_log_Apple_Terminal_2025-12-19T19-53-54Z.jsonl
    • mouse_scroll_log_WarpTerminal_2025-12-19T19-59-38Z.jsonl
    • mouse_scroll_log_WezTerm_2025-12-19T20-00-36Z.jsonl
    • mouse_scroll_log_alacritty_2025-12-19T19-56-45Z.jsonl
    • mouse_scroll_log_ghostty_2025-12-19T19-52-44Z.jsonl
    • mouse_scroll_log_iTerm_app_2025-12-19T19-55-08Z.jsonl
    • mouse_scroll_log_vscode_2025-12-19T19-51-20Z.jsonl
    • mouse_scroll_log_xterm-kitty_2025-12-19T19-58-19Z.jsonl

Per-terminal burst separability (wheel vs trackpad), summarized as median and p90:

  • Apple Terminal:
    • Wheel: length median 9.5 (p90 49), span median 94 ms (p90 136)
    • Trackpad: length median 13.5 (p90 104), span median 238 ms (p90 616)
  • Warp:
    • Wheel: length median 43 (p90 169), span median 88 ms (p90 178)
    • Trackpad: length median 60 (p90 82), span median 358 ms (p90 721)
  • WezTerm:
    • Wheel: length median 4 (p90 10), span median 91 ms (p90 156)
    • Trackpad: length median 10.5 (p90 36), span median 270 ms (p90 348)
  • alacritty:
    • Wheel: length median 14 (p90 63), span median 109 ms (p90 158)
    • Trackpad: length median 12.5 (p90 63), span median 372 ms (p90 883)
  • ghostty:
    • Wheel: length median 32.5 (p90 163), span median 99 ms (p90 157)
    • Trackpad: length median 14.5 (p90 60), span median 366 ms (p90 719)
  • iTerm:
    • Wheel: length median 4 (p90 9), span median 91 ms (p90 230)
    • Trackpad: length median 9 (p90 36), span median 223 ms (p90 540)
  • VS Code:
    • Wheel: length median 3 (p90 9), span median 94 ms (p90 120)
    • Trackpad: length median 3 (p90 12), span median 192 ms (p90 468)
  • Kitty:
    • Wheel: length median 15.5 (p90 59), span median 87 ms (p90 233)
    • Trackpad: length median 15.5 (p90 68), span median 292 ms (p90 563)

Wheel_single medians (events per tick) in the latest logs:

  • Apple: 3
  • Warp: 9
  • WezTerm: 1
  • alacritty: 3
  • ghostty: 9 (measured); TUI2 defaults use 3 because an upstream Ghostty change is expected to reduce wheel event density. If your Ghostty build still emits ~9 events per wheel tick, set tui.scroll_events_per_tick = 9.
  • iTerm: 1
  • VS Code: 1
  • Kitty: 3

Appendix B: Scroll probe findings (authoritative; preserved verbatim)

The remainder of this document is preserved from the original scroll-probe spec. It is intentionally not rewritten so the data and rationale remain auditable.

Note: the original text uses "events per line" terminology; the implementation treats this as an events-per-wheel-tick normalization factor (see "Normalization: events-per-tick").

Note: the pseudocode in the preserved spec is not the exact current implementation; it is kept as historical context for how the probe data originally mapped into an algorithm. The current implementation is described in the sections above.

1. TL;DR

Analysis of 16 scroll-probe logs (13,734 events) across 8 terminals shows large per-terminal variation in how many raw events are emitted per physical wheel tick (1-9+ events). Timing alone does not distinguish wheel vs trackpad; event counts and burst duration are more reliable. The algorithm below treats scroll input as short streams separated by gaps, normalizes events into line deltas using a per-terminal events-per-line factor, coalesces redraws at 60 Hz, and applies a minimum 1-line delta for discrete bursts. This yields stable behavior across dense streams, sparse bursts, and terminals that emit horizontal events.

2. Data overview

  • Logs analyzed: 16
  • Total events: 13,734
  • Terminals covered:
    • Apple_Terminal 455.1
    • WarpTerminal v0.2025.12.17.17.stable_02
    • WezTerm 20240203-110809-5046fc22
    • alacritty
    • ghostty 1.2.3
    • iTerm.app 3.6.6
    • vscode 1.107.1
    • xterm-kitty
  • Scenarios captured: wheel_single, wheel_small, wheel_long, trackpad_single, trackpad_slow, trackpad_fast (directional up/down variants treated as distinct bursts).
  • Legacy wheel_scroll_* logs are mapped to wheel_small in analysis.

3. Cross-terminal comparison table

Terminal Scenario Median Dt (ms) P95 Dt (ms) Typical burst Notes
Apple_Terminal 455.1 wheel_single 0.14 97.68 3
Apple_Terminal 455.1 wheel_small 0.12 23.81 19
Apple_Terminal 455.1 wheel_long 0.03 15.93 48
Apple_Terminal 455.1 trackpad_single 92.35 213.15 2
Apple_Terminal 455.1 trackpad_slow 11.30 75.46 14
Apple_Terminal 455.1 trackpad_fast 0.13 8.92 96
WarpTerminal v0.2025.12.17.17.stable_02 wheel_single 0.07 0.34 9
WarpTerminal v0.2025.12.17.17.stable_02 wheel_small 0.05 5.04 65
WarpTerminal v0.2025.12.17.17.stable_02 wheel_long 0.01 0.42 166
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_single 9.77 32.64 10
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_slow 7.93 16.44 74
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_fast 5.40 10.04 74
WezTerm 20240203-110809-5046fc22 wheel_single 416.07 719.64 1
WezTerm 20240203-110809-5046fc22 wheel_small 19.41 50.19 6
WezTerm 20240203-110809-5046fc22 wheel_long 13.19 29.96 10
WezTerm 20240203-110809-5046fc22 trackpad_single 237.56 237.56 1
WezTerm 20240203-110809-5046fc22 trackpad_slow 23.54 76.10 10 12.5% horiz
WezTerm 20240203-110809-5046fc22 trackpad_fast 7.10 24.86 32 12.6% horiz
alacritty wheel_single 0.09 0.33 3
alacritty wheel_small 0.11 37.24 24
alacritty wheel_long 0.01 15.96 56
alacritty trackpad_single n/a n/a 1
alacritty trackpad_slow 41.90 97.36 11
alacritty trackpad_fast 3.07 25.13 62
ghostty 1.2.3 wheel_single 0.05 0.20 9
ghostty 1.2.3 wheel_small 0.05 7.18 52
ghostty 1.2.3 wheel_long 0.02 1.16 146
ghostty 1.2.3 trackpad_single 61.28 124.28 3 23.5% horiz
ghostty 1.2.3 trackpad_slow 23.10 76.30 14 34.7% horiz
ghostty 1.2.3 trackpad_fast 3.84 37.72 47 23.4% horiz
iTerm.app 3.6.6 wheel_single 74.96 80.61 1
iTerm.app 3.6.6 wheel_small 20.79 84.83 6
iTerm.app 3.6.6 wheel_long 16.70 50.91 9
iTerm.app 3.6.6 trackpad_single n/a n/a 1
iTerm.app 3.6.6 trackpad_slow 17.25 94.05 9
iTerm.app 3.6.6 trackpad_fast 7.12 24.54 33
vscode 1.107.1 wheel_single 58.01 58.01 1
vscode 1.107.1 wheel_small 16.76 66.79 5
vscode 1.107.1 wheel_long 9.86 32.12 8
vscode 1.107.1 trackpad_single n/a n/a 1
vscode 1.107.1 trackpad_slow 164.19 266.90 3
vscode 1.107.1 trackpad_fast 16.78 61.05 11
xterm-kitty wheel_single 0.16 51.74 3
xterm-kitty wheel_small 0.10 24.12 26
xterm-kitty wheel_long 0.01 16.10 56
xterm-kitty trackpad_single 155.65 289.87 1 12.5% horiz
xterm-kitty trackpad_slow 16.89 67.04 16 30.4% horiz
xterm-kitty trackpad_fast 0.23 16.37 78 20.6% horiz

4. Key findings

  • Raw wheel ticks vary by terminal: median events per tick are 1 (WezTerm/iTerm/vscode), 3 (Apple/alacritty/kitty), and 9 (Warp/ghostty).
  • Trackpad bursts are longer than wheel ticks but overlap in timing; inter-event timing alone does not distinguish device type.
  • Continuous streams have short gaps: overall inter-event p99 is 70.67 ms; trackpad_slow p95 is 66.98 ms.
  • Horizontal events appear only in trackpad scenarios and only in WezTerm/ghostty/kitty; ignore horizontal events for vertical scrolling.
  • Burst duration is a reliable discrete/continuous signal:
    • wheel_single median 0.15 ms (p95 80.61 ms)
    • trackpad_single median 0 ms (p95 237.56 ms)
    • wheel_small median 96.88 ms (p95 182.90 ms)
    • trackpad_slow median 320.69 ms (p95 812.10 ms)

5. Scrolling model (authoritative)

Stream detection. Treat scroll input as short streams separated by silence. A stream begins on the first scroll event and ends when the gap since the last event exceeds STREAM_GAP_MS or the direction flips. Direction flip immediately closes the current stream and starts a new one.

Normalization. Convert raw events into line deltas using a per-terminal EVENTS_PER_LINE factor derived from the terminal's median wheel_single burst length. If no terminal override matches, use the global default (3).

Discrete vs continuous. Classify the stream after it ends:

  • If event_count <= DISCRETE_MAX_EVENTS and duration_ms <= DISCRETE_MAX_DURATION_MS, treat as discrete.
  • Otherwise treat as continuous.

Discrete streams. Apply the accumulated line delta immediately. If the stream's accumulated lines rounds to 0 but events were received, apply a minimum +/-1 line (respecting direction).

Continuous streams. Accumulate fractional lines and coalesce redraws to REDRAW_CADENCE_MS. Flush any remaining fractional lines on stream end (with the same +/-1 minimum if the stream had events but rounded to 0).

Direction. Always use the raw event direction. Provide a separate user-level invert option if needed; do not infer inversion from timing.

Horizontal events. Ignore horizontal events in vertical scroll logic.

6. Concrete constants (data-derived)

STREAM_GAP_MS                 = 80
DISCRETE_MAX_EVENTS           = 10
DISCRETE_MAX_DURATION_MS      = 250
REDRAW_CADENCE_MS             = 16
DEFAULT_EVENTS_PER_LINE       = 3
MAX_EVENTS_PER_STREAM         = 256
MAX_ACCUMULATED_LINES         = 256
MIN_LINES_PER_DISCRETE_STREAM = 1
DEFAULT_WHEEL_LINES_PER_TICK  = 3

Why these values:

  • STREAM_GAP_MS=80: overall p99 inter-event gap is 70.67 ms; trackpad_slow p95 is 66.98 ms. 80 ms ends streams without splitting most continuous input.
  • DISCRETE_MAX_EVENTS=10: wheel_single p95 burst = 9; trackpad_single p95 burst = 10.
  • DISCRETE_MAX_DURATION_MS=250: trackpad_single p95 duration = 237.56 ms.
  • REDRAW_CADENCE_MS=16: coalesces dense streams to ~60 Hz; trackpad_fast p95 Dt = 19.83 ms.
  • DEFAULT_EVENTS_PER_LINE=3: global median wheel_single burst length.
  • MAX_EVENTS_PER_STREAM=256 and MAX_ACCUMULATED_LINES=256: highest observed burst is 206; cap to avoid floods.
  • DEFAULT_WHEEL_LINES_PER_TICK=3: restores classic wheel speed; this is a UX choice rather than a data-derived constant.

7. Pseudocode (Rust-oriented)

// This is intentionally a simplified sketch of the current implementation.
// For the authoritative behavior, see `codex-rs/tui2/src/tui/scrolling/mouse.rs`.

enum StreamKind {
    Unknown,
    Wheel,
    Trackpad,
}

struct Stream {
    start: Instant,
    last: Instant,
    dir: i32,
    event_count: usize,
    accumulated_events: i32,
    applied_lines: i32,
    kind: StreamKind,
    just_promoted: bool,
}

struct State {
    stream: Option<Stream>,
    carry_lines: f32,
    last_redraw_at: Instant,
    cfg: Config,
}

struct Config {
    events_per_tick: u16,
    wheel_lines_per_tick: u16,
    trackpad_lines_per_tick: u16,
    trackpad_accel_events: u16,
    trackpad_accel_max: u16,
    wheel_tick_detect_max: Duration,
}

fn on_scroll_event(dir: i32, now: Instant, st: &mut State) -> i32 {
    // Close stream on idle gap or direction flip.
    if let Some(stream) = st.stream.as_ref() {
        let gap = now.duration_since(stream.last);
        if gap > STREAM_GAP || stream.dir != dir {
            finalize_stream(now, st);
            st.stream = None;
        }
    }

    let stream = st.stream.get_or_insert_with(|| Stream {
        start: now,
        last: now,
        dir,
        event_count: 0,
        accumulated_events: 0,
        applied_lines: 0,
        kind: StreamKind::Unknown,
        just_promoted: false,
    });

    stream.last = now;
    stream.dir = dir;
    stream.event_count = (stream.event_count + 1).min(MAX_EVENTS_PER_STREAM);
    stream.accumulated_events =
        (stream.accumulated_events + dir).clamp(-(MAX_EVENTS_PER_STREAM as i32), MAX_EVENTS_PER_STREAM as i32);

    // Auto-mode promotion: promote to wheel-like when the first tick-worth of events arrives quickly.
    if matches!(stream.kind, StreamKind::Unknown) {
        let ept = st.cfg.events_per_tick.max(1) as usize;
        if ept >= 2 && stream.event_count >= ept && now.duration_since(stream.start) <= st.cfg.wheel_tick_detect_max {
            stream.kind = StreamKind::Wheel;
            stream.just_promoted = true;
        }
    }

    flush_lines(now, st)
}

fn on_tick(now: Instant, st: &mut State) -> i32 {
    if let Some(stream) = st.stream.as_ref() {
        let gap = now.duration_since(stream.last);
        if gap > STREAM_GAP {
            return finalize_stream(now, st);
        }
    }
    flush_lines(now, st)
}

fn finalize_stream(now: Instant, st: &mut State) -> i32 {
    // In auto mode, any stream that isn't wheel-like by promotion stays trackpad-like.
    if let Some(stream) = st.stream.as_mut() {
        if matches!(stream.kind, StreamKind::Unknown) {
            stream.kind = StreamKind::Trackpad;
        }
    }

    let lines = flush_lines(now, st);

    // Carry fractional remainder across streams for trackpad-like input.
    if let Some(stream) = st.stream.as_ref() {
        if matches!(stream.kind, StreamKind::Trackpad) {
            st.carry_lines = desired_lines_f32(st, stream) - stream.applied_lines as f32;
        } else {
            st.carry_lines = 0.0;
        }
    }

    lines
}

fn flush_lines(now: Instant, st: &mut State) -> i32 {
    let Some(stream) = st.stream.as_mut() else { return 0; };

    let wheel_like = matches!(stream.kind, StreamKind::Wheel);
    let cadence_elapsed = now.duration_since(st.last_redraw_at) >= REDRAW_CADENCE;
    let should_flush = wheel_like || cadence_elapsed || stream.just_promoted;
    if !should_flush {
        return 0;
    }

    let desired_total = desired_lines_f32(st, stream);
    let mut desired_lines = desired_total.trunc() as i32;

    // Wheel guardrail: ensure we never produce a "dead tick" for non-zero input.
    if wheel_like && desired_lines == 0 && stream.accumulated_events != 0 {
        desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_DISCRETE_STREAM;
    }

    let mut delta = desired_lines - stream.applied_lines;
    if delta == 0 {
        return 0;
    }

    delta = delta.clamp(-MAX_ACCUMULATED_LINES, MAX_ACCUMULATED_LINES);
    stream.applied_lines += delta;
    stream.just_promoted = false;
    st.last_redraw_at = now;
    delta
}

fn desired_lines_f32(st: &State, stream: &Stream) -> f32 {
    let wheel_like = matches!(stream.kind, StreamKind::Wheel);

    let events_per_tick = if wheel_like {
        st.cfg.events_per_tick.max(1) as f32
    } else {
        // Trackpad divisor is capped so dense wheel terminals don't feel slow for trackpads.
        st.cfg.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_LINE).max(1) as f32
    };

    let lines_per_tick = if wheel_like {
        st.cfg.wheel_lines_per_tick.max(1) as f32
    } else {
        st.cfg.trackpad_lines_per_tick.max(1) as f32
    };

    let mut total = (stream.accumulated_events as f32 * (lines_per_tick / events_per_tick))
        .clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);

    if !wheel_like {
        total = (total + st.carry_lines).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);

        // Bounded acceleration for large swipes (keep small swipes precise).
        let event_count = stream.accumulated_events.abs() as f32;
        let accel = (1.0 + (event_count / st.cfg.trackpad_accel_events.max(1) as f32))
            .clamp(1.0, st.cfg.trackpad_accel_max.max(1) as f32);
        total = (total * accel).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);
    }

    total
}

8. Terminal-specific adjustments (minimal)

Use per-terminal EVENTS_PER_LINE overrides derived from median wheel_single bursts:

Apple_Terminal 455.1                     = 3
WarpTerminal v0.2025.12.17.17.stable_02  = 9
WezTerm 20240203-110809-5046fc22         = 1
alacritty                                 = 3
ghostty 1.2.3                             = 9
iTerm.app 3.6.6                           = 1
vscode 1.107.1                            = 1
xterm-kitty                               = 3

If terminal is not matched, use DEFAULT_EVENTS_PER_LINE = 3.

9. Known weird cases and guardrails

  • Extremely dense streams (sub-ms Dt) occur in Warp/ghostty/kitty; redraw coalescing is mandatory.
  • Sparse bursts (hundreds of ms between events) occur in trackpad_single; do not merge them into long streams.
  • Horizontal scroll events (12-35% of trackpad events in some terminals) must be ignored for vertical scrolling.
  • Direction inversion is user-configurable in terminals; always use event direction and expose an application-level invert setting.
  • Guard against floods: cap event counts and accumulated line deltas per stream.