From 4dd51f4a5f2037f8aa322fe7807315e6530a4ec8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 2 Oct 2026 06:41:33 +0000 Subject: [PATCH] Make transcript mouse scroll speed configurable (#50209) ## What changed Add `tui.mouse_scroll_speed` as a finite positive multiplier for transcript mouse wheel scrolling, including transcript overlays and session previews. Change the default from three rows to one row per wheel event; set the multiplier to `3.0` to retain the previous speed. Fractional values accumulate movement across events and discard the remainder when direction reverses. For example, `0.5` scrolls one row every two events. Following the latest output pauses only when a whole row moves. ## Testing Add tests for configuration validation, integer and fractional scroll speeds, direction changes, decimal accumulation, and following behavior. Extend local settings coverage to include a command-line override for the new setting. GitOrigin-RevId: d355e246fd943415f0ce5a6affe849f77f3615e6 --- codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/tui_mouse_scroll.rs | 26 +++++ codex-rs/config/src/types.rs | 6 + codex-rs/config/src/types_tests.rs | 21 ++++ codex-rs/core/config.schema.json | 7 ++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 4 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/tui/src/app/owned_transcript.rs | 1 + ...pt__input_tests__plan_prompt_scrolled.snap | 4 +- codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/app_backtrack.rs | 1 + .../tui/src/clipboard_copy/worker_tests.rs | 1 + codex-rs/tui/src/local_settings.rs | 1 + codex-rs/tui/src/local_settings_tests.rs | 4 + codex-rs/tui/src/pager_overlay.rs | 2 + codex-rs/tui/src/resume_picker.rs | 7 ++ ...sts__wheel_stops_selection_autoscroll.snap | 16 +-- codex-rs/tui/src/transcript_view.rs | 4 + codex-rs/tui/src/transcript_view/input.rs | 24 +++- .../tui/src/transcript_view/input_tests.rs | 108 +++++++++++++++++- ...s_and_accumulates_fractional_movement.snap | 27 +++++ 22 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 codex-rs/config/src/tui_mouse_scroll.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__tests__mouse_scroll_speed_scales_rows_and_accumulates_fractional_movement.snap diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index d90d57ae87..681488a6b6 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -45,6 +45,7 @@ pub mod test_support; mod thread_config; mod tui_effects; mod tui_keymap; +mod tui_mouse_scroll; mod tui_rendering; pub mod types; diff --git a/codex-rs/config/src/tui_mouse_scroll.rs b/codex-rs/config/src/tui_mouse_scroll.rs new file mode 100644 index 0000000000..345f994183 --- /dev/null +++ b/codex-rs/config/src/tui_mouse_scroll.rs @@ -0,0 +1,26 @@ +//! Keep transcript mouse-scroll speed validation and its configuration schema in sync. + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::Deserialize; +use serde::Deserializer; + +pub(crate) fn schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = Option::::json_schema(generator).into_object(); + schema.number().exclusive_minimum = Some(0.0); + schema.into() +} + +pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let speed = Option::::deserialize(deserializer)?; + if speed.is_some_and(|speed| !speed.is_finite() || speed <= 0.0) { + return Err(serde::de::Error::custom( + "tui.mouse_scroll_speed must be a finite positive number", + )); + } + Ok(speed) +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index e16333c4a1..86d9134a2b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -861,6 +861,12 @@ pub struct Tui { #[serde(default = "default_true")] pub fullscreen_transcript: bool, + /// Mouse wheel speed multiplier for transcript scrolling, based on one row per event. + /// Defaults to `1.0`. Positive fractional values slow scrolling; values above `1.0` speed it up. + #[serde(default, deserialize_with = "crate::tui_mouse_scroll::deserialize")] + #[schemars(schema_with = "crate::tui_mouse_scroll::schema")] + pub mouse_scroll_speed: Option, + /// Copy selected transcript text when the mouse button is released. /// Defaults to `auto`: enabled except in direct terminals known to forward their native /// copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). diff --git a/codex-rs/config/src/types_tests.rs b/codex-rs/config/src/types_tests.rs index ab383ca193..5a9b3d81d9 100644 --- a/codex-rs/config/src/types_tests.rs +++ b/codex-rs/config/src/types_tests.rs @@ -1,6 +1,27 @@ use super::*; use pretty_assertions::assert_eq; +#[test] +fn mouse_scroll_speed_accepts_integer_and_fractional_multipliers() { + for (value, expected) in [("1", 1.0), ("0.5", 0.5), ("3.0", 3.0)] { + let tui: Tui = toml::from_str(&format!("mouse_scroll_speed = {value}")).unwrap(); + assert_eq!(tui.mouse_scroll_speed, Some(expected)); + } +} + +#[test] +fn mouse_scroll_speed_rejects_nonpositive_and_nonfinite_values() { + for value in ["0", "-0.5", "nan", "inf", "-inf"] { + let error = toml::from_str::(&format!("mouse_scroll_speed = {value}")).unwrap_err(); + assert!( + error + .to_string() + .contains("tui.mouse_scroll_speed must be a finite positive number"), + "{error}" + ); + } +} + #[test] fn deserialize_skill_config_with_name_selector() { let cfg: SkillConfig = toml::from_str( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1c736947d8..1ab7592e96 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4686,6 +4686,13 @@ "default": {}, "description": "Startup tooltip availability NUX state persisted by the TUI." }, + "mouse_scroll_speed": { + "default": null, + "description": "Mouse wheel speed multiplier for transcript scrolling, based on one row per event. Defaults to `1.0`. Positive fractional values slow scrolling; values above `1.0` speed it up.", + "exclusiveMinimum": 0.0, + "format": "double", + "type": "number" + }, "notification_condition": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 79ab58e75c..d07c42ff97 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1269,6 +1269,7 @@ fn config_toml_deserializes_model_availability_nux() { question_esc_back: true, raw_output_mode: false, fullscreen_transcript: true, + mouse_scroll_speed: None, copy_on_select: Default::default(), right_click_paste: Default::default(), alternate_screen: AltScreenMode::default(), @@ -4410,6 +4411,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { question_esc_back: true, raw_output_mode: false, fullscreen_transcript: true, + mouse_scroll_speed: None, copy_on_select: Default::default(), right_click_paste: Default::default(), alternate_screen: AltScreenMode::Auto, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fe06279954..704c949831 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -803,6 +803,9 @@ pub struct Config { /// Own the fullscreen transcript when the alternate screen is enabled. pub tui_fullscreen_transcript: bool, + /// Mouse wheel speed multiplier for transcript scrolling; defaults to one row per event. + pub tui_mouse_scroll_speed: Option, + /// Override the terminal-specific default for copying transcript mouse selections. pub tui_copy_on_select: codex_config::types::CopyOnSelect, @@ -4533,6 +4536,7 @@ impl Config { .tui .as_ref() .is_none_or(|tui| tui.fullscreen_transcript), + tui_mouse_scroll_speed: cfg.tui.as_ref().and_then(|tui| tui.mouse_scroll_speed), tui_copy_on_select: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 1259a62b49..d2bec6be25 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -290,6 +290,7 @@ async fn new_config( tui_auto_recap: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), tui_fullscreen_transcript: false, + tui_mouse_scroll_speed: None, tui_copy_on_select: Default::default(), tui_right_click_paste: Default::default(), tui_alternate_screen: AltScreenMode::Auto, diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index aa442e77ba..0893ad1791 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -42,6 +42,7 @@ impl App { let chat_widget = &self.chat_widget; let transcript_width = chat_widget.history_wrap_width(width); let view = &mut self.transcript_view; + view.mouse_scroll_speed = self.local_settings.tui.mouse_scroll_speed.unwrap_or(1.0); view.primary_selection = self.right_click_paste_environment.primary; view.copy_on_select = self .local_settings diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__plan_prompt_scrolled.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__plan_prompt_scrolled.snap index e6e3ae62af..e01c019c45 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__plan_prompt_scrolled.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__plan_prompt_scrolled.snap @@ -2,8 +2,6 @@ source: tui/src/app/owned_transcript_input_tests.rs expression: scrolled --- - • Plan step 27 - • Plan step 28 • Plan step 29 • Plan step 30 • Plan step 31 @@ -18,6 +16,8 @@ expression: scrolled • Plan step 40 + + Implement this plan? diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 68504517d0..26b3cc9c80 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -9757,6 +9757,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, /*copy_on_select*/ false, + /*mouse_scroll_speed*/ 1.0, )); app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 3a555a808d..762cdb0fa6 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -171,6 +171,7 @@ impl App { self.keymap.pager.clone(), self.local_settings .copy_on_select(&codex_terminal_detection::terminal_info()), + self.local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), )); if self.scrollback_has_older_history && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs index d030594fd1..1805813f53 100644 --- a/codex-rs/tui/src/clipboard_copy/worker_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -82,6 +82,7 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], RuntimeKeymap::defaults().pager, /*copy_on_select*/ false, + /*mouse_scroll_speed*/ 1.0, ); overlay .handle_event( diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 949e6f9e7f..b8c1dba6c5 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -68,6 +68,7 @@ impl LocalSettings { question_esc_back: config.tui_question_esc_back, raw_output_mode: config.tui_raw_output_mode, fullscreen_transcript: config.tui_fullscreen_transcript, + mouse_scroll_speed: config.tui_mouse_scroll_speed, copy_on_select: config.tui_copy_on_select, right_click_paste: config.tui_right_click_paste, alternate_screen: config.tui_alternate_screen, diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 8b95d8898f..17c6a5310c 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -105,6 +105,7 @@ show_tooltips = false show_server_version_notice = false auto_recap = false fullscreen_transcript = true +mouse_scroll_speed = 0.5 copy_on_select = "never" right_click_paste = "off" vim_mode_default = true @@ -136,6 +137,7 @@ fast_default_opt_out = true .cli_overrides(vec![ ("tui.disable_paste_burst".into(), true.into()), ("tui.right_click_paste".into(), "on".into()), + ("tui.mouse_scroll_speed".into(), 1.5.into()), // The deprecated flag must not override or migrate into the TUI preference. ( "features.transcript_v2".into(), @@ -145,12 +147,14 @@ fast_default_opt_out = true .build() .await?; assert_eq!(config.startup_warnings, Vec::::new()); + assert_eq!(config.tui_mouse_scroll_speed, Some(1.5)); let bootstrap = crate::legacy_core::config::load_config_toml_with_layer_stack( home.path(), /*cwd*/ None, vec![ ("tui.disable_paste_burst".into(), true.into()), ("tui.right_click_paste".into(), "on".into()), + ("tui.mouse_scroll_speed".into(), 1.5.into()), ( "features.transcript_v2".into(), config_text.is_empty().into(), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index dde4e52f4e..685fff820e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -52,9 +52,11 @@ impl Overlay { cells: Vec>, keymap: PagerKeymap, copy_on_select: bool, + mouse_scroll_speed: f64, ) -> Self { let mut overlay = TranscriptOverlay::new(cells, keymap); overlay.view.copy_on_select = copy_on_select; + overlay.view.mouse_scroll_speed = mouse_scroll_speed; overlay.view.primary_selection = crate::clipboard_copy::primary::available(); Self::Transcript(overlay) } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 5d904ee4d3..cef2cb9472 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -336,6 +336,7 @@ struct SessionPickerViewPersistence { struct SessionPickerRunOptions { use_theme_colors: bool, copy_on_select: bool, + mouse_scroll_speed: f64, show_all: bool, filter_cwd: Option, local_filter_cwd: Option, @@ -447,6 +448,7 @@ async fn run_resume_picker_with_launch_context( let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), + mouse_scroll_speed: local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), show_all, filter_cwd: cwd_filter, local_filter_cwd, @@ -507,6 +509,7 @@ pub async fn run_fork_picker_with_app_server( let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), + mouse_scroll_speed: local_settings.tui.mouse_scroll_speed.unwrap_or(1.0), show_all, filter_cwd: cwd_filter, local_filter_cwd, @@ -562,6 +565,7 @@ async fn run_session_picker_with_loader( state.local_filter_cwd = options.local_filter_cwd; state.use_theme_colors = options.use_theme_colors; state.copy_on_select = options.copy_on_select; + state.mouse_scroll_speed = options.mouse_scroll_speed; state.worktrees_enabled = options.worktrees_enabled; state.density = options.initial_density; state.view_persistence = options.view_persistence; @@ -835,6 +839,7 @@ struct PickerState { clock_format: ClockFormat, use_theme_colors: bool, copy_on_select: bool, + mouse_scroll_speed: f64, // Resolve local filesystem membership once per cwd for each page-loading cycle. local_cwd_matches: HashMap, requester: FrameRequester, @@ -1036,6 +1041,7 @@ impl PickerState { clock_format: ClockFormat::system(), use_theme_colors: true, copy_on_select: false, + mouse_scroll_speed: 1.0, requester, relative_time_reference: None, pagination: PaginationState::new(), @@ -1141,6 +1147,7 @@ impl PickerState { cells.clone(), self.keymap.pager.clone(), self.copy_on_select, + self.mouse_scroll_speed, ); if let Overlay::Transcript(view) = &mut overlay { view.set_keymap_bindings(&self.keymap); diff --git a/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap index e9d162d774..0b35d77570 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__wheel_stops_selection_autoscroll.snap @@ -2,16 +2,16 @@ source: tui/src/transcript_view_tests.rs expression: "frames.join(\"\\n\\n\")" --- -row 15 -row 16 -row 17 -row 18 row 19 row 20 +row 21 +row 22 +row 23 +row 24 +row 25 +row 26 +row 27 +row 28 row 29 row 30 -row 31 -row 32 -row 33 -row 34 diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 5a7a7e749e..9f04f3114a 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -85,6 +85,8 @@ struct VisibleRow { /// Shared scrolling and interaction state for compact and detailed transcript presentations. pub(crate) struct TranscriptView { + pub(crate) mouse_scroll_speed: f64, + pending_mouse_scroll: f64, pub(crate) copy_on_select: bool, pub(crate) primary_selection: bool, position: Position, @@ -118,6 +120,8 @@ pub(crate) struct TranscriptView { impl Default for TranscriptView { fn default() -> Self { Self { + mouse_scroll_speed: 1.0, + pending_mouse_scroll: 0.0, copy_on_select: false, primary_selection: false, position: Position::Latest, diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 6479eb2b11..d3d5be9f9c 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -3,6 +3,7 @@ //! Shift-click extends the existing selection from its original text unit. //! Optional automatic copying happens only when a nonempty mouse selection is released. //! Automatic copies retain the selection; explicit copies clear it after confirmed delivery. +//! Wheel input accumulates fractional rows and discards the remainder when direction reverses. use crate::key_hint::KeyBindingListExt; use crossterm::event::KeyCode; @@ -232,8 +233,27 @@ impl TranscriptView { selection.pointer = None; } match event.kind { - MouseEventKind::ScrollUp => self.scroll(cells, /*rows*/ -3), - MouseEventKind::ScrollDown => self.scroll(cells, /*rows*/ 3), + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let direction = if event.kind == MouseEventKind::ScrollUp { + -1.0 + } else { + 1.0 + }; + if self.pending_mouse_scroll.signum() != direction { + self.pending_mouse_scroll = 0.0; + } + self.pending_mouse_scroll += direction * self.mouse_scroll_speed; + // Decimal speeds can land just short of a whole row after repeated addition. + let rounded = self.pending_mouse_scroll.round(); + if rounded != 0.0 && (self.pending_mouse_scroll - rounded).abs() < 1e-9 { + self.pending_mouse_scroll = rounded; + } + let rows = self.pending_mouse_scroll as isize; + self.pending_mouse_scroll = self.pending_mouse_scroll.fract(); + if rows != 0 { + self.scroll(cells, rows); + } + } MouseEventKind::Down(MouseButton::Right) if inside => { return self .selected_text(cells) diff --git a/codex-rs/tui/src/transcript_view/input_tests.rs b/codex-rs/tui/src/transcript_view/input_tests.rs index 45f408cce4..e996f0006c 100644 --- a/codex-rs/tui/src/transcript_view/input_tests.rs +++ b/codex-rs/tui/src/transcript_view/input_tests.rs @@ -1,9 +1,115 @@ -//! Link activation and selection extension share pointer gestures without losing selection anchors. +//! Mouse gestures preserve selection anchors and accumulate fractional transcript scrolling. use super::*; use crate::history_cell::AgentMarkdownCell; use pretty_assertions::assert_eq; +#[test] +fn mouse_scroll_speed_scales_rows_and_accumulates_fractional_movement() { + use crate::history_cell::PlainHistoryCell; + use crate::transcript_view::tests::text; + + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new( + (1..=24).map(|row| format!("row {row:02}").into()).collect(), + ))]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4, + ); + let mut frames = Vec::new(); + for (speed, deltas) in [ + (None, vec![-1, 1]), + (Some(0.5), vec![0, -1, 0, 0, 1]), + (Some(1.5), vec![-1, -2, 1, 2]), + (Some(3.0), vec![-3, 3]), + ] { + let mut view = TranscriptView::default(); + if let Some(speed) = speed { + view.mouse_scroll_speed = speed; + } + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + view.scroll(&cells, /*rows*/ -8); + view.render(area, &mut buffer, &cells); + let (index, mut row) = view.start(&cells); + let up_events = deltas.len().div_ceil(2); + for (event_index, delta) in deltas.into_iter().enumerate() { + let kind = if event_index < up_events { + MouseEventKind::ScrollUp + } else { + MouseEventKind::ScrollDown + }; + view.handle_mouse(mouse(kind, /*column*/ 1, /*row*/ 1), &cells); + row = row.saturating_add_signed(delta); + assert_eq!(view.start(&cells), (index, row)); + view.render(area, &mut buffer, &cells); + if event_index + 1 == up_events { + frames.push(format!( + "speed {speed:?}, {up_events} up events:\n{}", + text(&buffer) + )); + } + } + } + insta::assert_snapshot!(frames.join("\n\n")); +} + +#[test] +fn decimal_mouse_scroll_speeds_preserve_whole_rows() { + use crate::history_cell::PlainHistoryCell; + + let cells: Vec> = vec![Arc::new(PlainHistoryCell::new( + (1..=120) + .map(|row| format!("row {row:03}").into()) + .collect(), + ))]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4, + ); + for speed in [0.1, 0.3] { + let mut view = TranscriptView { + mouse_scroll_speed: speed, + ..TranscriptView::default() + }; + view.render(area, &mut Buffer::empty(area), &cells); + view.scroll(&cells, /*rows*/ -40); + let (index, row) = view.start(&cells); + for (kind, direction) in [ + (MouseEventKind::ScrollUp, -1), + (MouseEventKind::ScrollDown, 1), + ] { + let (_, start_row) = view.start(&cells); + for event in 1..=100 { + view.handle_mouse(mouse(kind, /*column*/ 1, /*row*/ 1), &cells); + let rows = (f64::from(event) * speed).floor() as isize * direction; + assert_eq!( + view.start(&cells), + (index, start_row.saturating_add_signed(rows)) + ); + } + } + assert_eq!(view.start(&cells), (index, row)); + } +} + +#[test] +fn fractional_mouse_scroll_does_not_pause_following_until_a_row_moves() { + let (mut view, cells) = transcript( + "one\n\ntwo\n\nthree\n\nfour\n\nfive\n\nsix", + /*width*/ 12, + ); + view.mouse_scroll_speed = 0.5; + view.handle_mouse( + mouse(MouseEventKind::ScrollUp, /*column*/ 4, /*row*/ 1), + &cells, + ); + assert!(view.is_following()); + view.handle_mouse( + mouse(MouseEventKind::ScrollUp, /*column*/ 4, /*row*/ 1), + &cells, + ); + assert!(!view.is_following()); +} + fn transcript(markdown: &str, width: u16) -> (TranscriptView, Vec>) { let cells: Vec> = vec![Arc::new(AgentMarkdownCell::new( markdown.into(), diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__tests__mouse_scroll_speed_scales_rows_and_accumulates_fractional_movement.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__tests__mouse_scroll_speed_scales_rows_and_accumulates_fractional_movement.snap new file mode 100644 index 0000000000..11d34d24a2 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__input__tests__mouse_scroll_speed_scales_rows_and_accumulates_fractional_movement.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/transcript_view/input_tests.rs +expression: "frames.join(\"\\n\\n\")" +--- +speed None, 1 up events: +row 12 +row 13 +row 14 +row 15 + +speed Some(0.5), 3 up events: +row 12 +row 13 +row 14 +row 15 + +speed Some(1.5), 2 up events: +row 10 +row 11 +row 12 +row 13 + +speed Some(3.0), 1 up events: +row 10 +row 11 +row 12 +row 13