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
This commit is contained in:
Eric Traut
2026-10-02 06:50:10 +00:00
committed by copyberry
parent e3c2a83937
commit 4dd51f4a5f
22 changed files with 256 additions and 13 deletions
+1
View File
@@ -45,6 +45,7 @@ pub mod test_support;
mod thread_config; mod thread_config;
mod tui_effects; mod tui_effects;
mod tui_keymap; mod tui_keymap;
mod tui_mouse_scroll;
mod tui_rendering; mod tui_rendering;
pub mod types; pub mod types;
+26
View File
@@ -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::<f64>::json_schema(generator).into_object();
schema.number().exclusive_minimum = Some(0.0);
schema.into()
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let speed = Option::<f64>::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)
}
+6
View File
@@ -861,6 +861,12 @@ pub struct Tui {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub fullscreen_transcript: bool, 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<f64>,
/// Copy selected transcript text when the mouse button is released. /// Copy selected transcript text when the mouse button is released.
/// Defaults to `auto`: enabled except in direct terminals known to forward their native /// 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). /// copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows).
+21
View File
@@ -1,6 +1,27 @@
use super::*; use super::*;
use pretty_assertions::assert_eq; 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::<Tui>(&format!("mouse_scroll_speed = {value}")).unwrap_err();
assert!(
error
.to_string()
.contains("tui.mouse_scroll_speed must be a finite positive number"),
"{error}"
);
}
}
#[test] #[test]
fn deserialize_skill_config_with_name_selector() { fn deserialize_skill_config_with_name_selector() {
let cfg: SkillConfig = toml::from_str( let cfg: SkillConfig = toml::from_str(
+7
View File
@@ -4686,6 +4686,13 @@
"default": {}, "default": {},
"description": "Startup tooltip availability NUX state persisted by the TUI." "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": { "notification_condition": {
"allOf": [ "allOf": [
{ {
+2
View File
@@ -1269,6 +1269,7 @@ fn config_toml_deserializes_model_availability_nux() {
question_esc_back: true, question_esc_back: true,
raw_output_mode: false, raw_output_mode: false,
fullscreen_transcript: true, fullscreen_transcript: true,
mouse_scroll_speed: None,
copy_on_select: Default::default(), copy_on_select: Default::default(),
right_click_paste: Default::default(), right_click_paste: Default::default(),
alternate_screen: AltScreenMode::default(), alternate_screen: AltScreenMode::default(),
@@ -4410,6 +4411,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
question_esc_back: true, question_esc_back: true,
raw_output_mode: false, raw_output_mode: false,
fullscreen_transcript: true, fullscreen_transcript: true,
mouse_scroll_speed: None,
copy_on_select: Default::default(), copy_on_select: Default::default(),
right_click_paste: Default::default(), right_click_paste: Default::default(),
alternate_screen: AltScreenMode::Auto, alternate_screen: AltScreenMode::Auto,
+4
View File
@@ -803,6 +803,9 @@ pub struct Config {
/// Own the fullscreen transcript when the alternate screen is enabled. /// Own the fullscreen transcript when the alternate screen is enabled.
pub tui_fullscreen_transcript: bool, pub tui_fullscreen_transcript: bool,
/// Mouse wheel speed multiplier for transcript scrolling; defaults to one row per event.
pub tui_mouse_scroll_speed: Option<f64>,
/// Override the terminal-specific default for copying transcript mouse selections. /// Override the terminal-specific default for copying transcript mouse selections.
pub tui_copy_on_select: codex_config::types::CopyOnSelect, pub tui_copy_on_select: codex_config::types::CopyOnSelect,
@@ -4533,6 +4536,7 @@ impl Config {
.tui .tui
.as_ref() .as_ref()
.is_none_or(|tui| tui.fullscreen_transcript), .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_copy_on_select: cfg
.tui .tui
.as_ref() .as_ref()
@@ -290,6 +290,7 @@ async fn new_config(
tui_auto_recap: true, tui_auto_recap: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_fullscreen_transcript: false, tui_fullscreen_transcript: false,
tui_mouse_scroll_speed: None,
tui_copy_on_select: Default::default(), tui_copy_on_select: Default::default(),
tui_right_click_paste: Default::default(), tui_right_click_paste: Default::default(),
tui_alternate_screen: AltScreenMode::Auto, tui_alternate_screen: AltScreenMode::Auto,
+1
View File
@@ -42,6 +42,7 @@ impl App {
let chat_widget = &self.chat_widget; let chat_widget = &self.chat_widget;
let transcript_width = chat_widget.history_wrap_width(width); let transcript_width = chat_widget.history_wrap_width(width);
let view = &mut self.transcript_view; 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.primary_selection = self.right_click_paste_environment.primary;
view.copy_on_select = self view.copy_on_select = self
.local_settings .local_settings
@@ -2,8 +2,6 @@
source: tui/src/app/owned_transcript_input_tests.rs source: tui/src/app/owned_transcript_input_tests.rs
expression: scrolled expression: scrolled
--- ---
• Plan step 27
• Plan step 28
• Plan step 29 • Plan step 29
• Plan step 30 • Plan step 30
• Plan step 31 • Plan step 31
@@ -18,6 +16,8 @@ expression: scrolled
• Plan step 40 • Plan step 40
Implement this plan? Implement this plan?
+1
View File
@@ -9757,6 +9757,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
app.transcript_cells.clone(), app.transcript_cells.clone(),
crate::keymap::RuntimeKeymap::defaults().pager, crate::keymap::RuntimeKeymap::defaults().pager,
/*copy_on_select*/ false, /*copy_on_select*/ false,
/*mouse_scroll_speed*/ 1.0,
)); ));
app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.has_emitted_history_lines = true; app.has_emitted_history_lines = true;
+1
View File
@@ -171,6 +171,7 @@ impl App {
self.keymap.pager.clone(), self.keymap.pager.clone(),
self.local_settings self.local_settings
.copy_on_select(&codex_terminal_detection::terminal_info()), .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 if self.scrollback_has_older_history
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
@@ -82,6 +82,7 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion()
vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))],
RuntimeKeymap::defaults().pager, RuntimeKeymap::defaults().pager,
/*copy_on_select*/ false, /*copy_on_select*/ false,
/*mouse_scroll_speed*/ 1.0,
); );
overlay overlay
.handle_event( .handle_event(
+1
View File
@@ -68,6 +68,7 @@ impl LocalSettings {
question_esc_back: config.tui_question_esc_back, question_esc_back: config.tui_question_esc_back,
raw_output_mode: config.tui_raw_output_mode, raw_output_mode: config.tui_raw_output_mode,
fullscreen_transcript: config.tui_fullscreen_transcript, fullscreen_transcript: config.tui_fullscreen_transcript,
mouse_scroll_speed: config.tui_mouse_scroll_speed,
copy_on_select: config.tui_copy_on_select, copy_on_select: config.tui_copy_on_select,
right_click_paste: config.tui_right_click_paste, right_click_paste: config.tui_right_click_paste,
alternate_screen: config.tui_alternate_screen, alternate_screen: config.tui_alternate_screen,
+4
View File
@@ -105,6 +105,7 @@ show_tooltips = false
show_server_version_notice = false show_server_version_notice = false
auto_recap = false auto_recap = false
fullscreen_transcript = true fullscreen_transcript = true
mouse_scroll_speed = 0.5
copy_on_select = "never" copy_on_select = "never"
right_click_paste = "off" right_click_paste = "off"
vim_mode_default = true vim_mode_default = true
@@ -136,6 +137,7 @@ fast_default_opt_out = true
.cli_overrides(vec![ .cli_overrides(vec![
("tui.disable_paste_burst".into(), true.into()), ("tui.disable_paste_burst".into(), true.into()),
("tui.right_click_paste".into(), "on".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. // The deprecated flag must not override or migrate into the TUI preference.
( (
"features.transcript_v2".into(), "features.transcript_v2".into(),
@@ -145,12 +147,14 @@ fast_default_opt_out = true
.build() .build()
.await?; .await?;
assert_eq!(config.startup_warnings, Vec::<String>::new()); assert_eq!(config.startup_warnings, Vec::<String>::new());
assert_eq!(config.tui_mouse_scroll_speed, Some(1.5));
let bootstrap = crate::legacy_core::config::load_config_toml_with_layer_stack( let bootstrap = crate::legacy_core::config::load_config_toml_with_layer_stack(
home.path(), home.path(),
/*cwd*/ None, /*cwd*/ None,
vec![ vec![
("tui.disable_paste_burst".into(), true.into()), ("tui.disable_paste_burst".into(), true.into()),
("tui.right_click_paste".into(), "on".into()), ("tui.right_click_paste".into(), "on".into()),
("tui.mouse_scroll_speed".into(), 1.5.into()),
( (
"features.transcript_v2".into(), "features.transcript_v2".into(),
config_text.is_empty().into(), config_text.is_empty().into(),
+2
View File
@@ -52,9 +52,11 @@ impl Overlay {
cells: Vec<Arc<dyn HistoryCell>>, cells: Vec<Arc<dyn HistoryCell>>,
keymap: PagerKeymap, keymap: PagerKeymap,
copy_on_select: bool, copy_on_select: bool,
mouse_scroll_speed: f64,
) -> Self { ) -> Self {
let mut overlay = TranscriptOverlay::new(cells, keymap); let mut overlay = TranscriptOverlay::new(cells, keymap);
overlay.view.copy_on_select = copy_on_select; 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(); overlay.view.primary_selection = crate::clipboard_copy::primary::available();
Self::Transcript(overlay) Self::Transcript(overlay)
} }
+7
View File
@@ -336,6 +336,7 @@ struct SessionPickerViewPersistence {
struct SessionPickerRunOptions { struct SessionPickerRunOptions {
use_theme_colors: bool, use_theme_colors: bool,
copy_on_select: bool, copy_on_select: bool,
mouse_scroll_speed: f64,
show_all: bool, show_all: bool,
filter_cwd: Option<PathBuf>, filter_cwd: Option<PathBuf>,
local_filter_cwd: Option<PathBuf>, local_filter_cwd: Option<PathBuf>,
@@ -447,6 +448,7 @@ async fn run_resume_picker_with_launch_context(
let options = SessionPickerRunOptions { let options = SessionPickerRunOptions {
use_theme_colors: local_settings.tui.status_line_use_colors, use_theme_colors: local_settings.tui.status_line_use_colors,
copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), 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, show_all,
filter_cwd: cwd_filter, filter_cwd: cwd_filter,
local_filter_cwd, local_filter_cwd,
@@ -507,6 +509,7 @@ pub async fn run_fork_picker_with_app_server(
let options = SessionPickerRunOptions { let options = SessionPickerRunOptions {
use_theme_colors: local_settings.tui.status_line_use_colors, use_theme_colors: local_settings.tui.status_line_use_colors,
copy_on_select: local_settings.copy_on_select(&codex_terminal_detection::terminal_info()), 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, show_all,
filter_cwd: cwd_filter, filter_cwd: cwd_filter,
local_filter_cwd, local_filter_cwd,
@@ -562,6 +565,7 @@ async fn run_session_picker_with_loader(
state.local_filter_cwd = options.local_filter_cwd; state.local_filter_cwd = options.local_filter_cwd;
state.use_theme_colors = options.use_theme_colors; state.use_theme_colors = options.use_theme_colors;
state.copy_on_select = options.copy_on_select; state.copy_on_select = options.copy_on_select;
state.mouse_scroll_speed = options.mouse_scroll_speed;
state.worktrees_enabled = options.worktrees_enabled; state.worktrees_enabled = options.worktrees_enabled;
state.density = options.initial_density; state.density = options.initial_density;
state.view_persistence = options.view_persistence; state.view_persistence = options.view_persistence;
@@ -835,6 +839,7 @@ struct PickerState {
clock_format: ClockFormat, clock_format: ClockFormat,
use_theme_colors: bool, use_theme_colors: bool,
copy_on_select: bool, copy_on_select: bool,
mouse_scroll_speed: f64,
// Resolve local filesystem membership once per cwd for each page-loading cycle. // Resolve local filesystem membership once per cwd for each page-loading cycle.
local_cwd_matches: HashMap<PathBuf, bool>, local_cwd_matches: HashMap<PathBuf, bool>,
requester: FrameRequester, requester: FrameRequester,
@@ -1036,6 +1041,7 @@ impl PickerState {
clock_format: ClockFormat::system(), clock_format: ClockFormat::system(),
use_theme_colors: true, use_theme_colors: true,
copy_on_select: false, copy_on_select: false,
mouse_scroll_speed: 1.0,
requester, requester,
relative_time_reference: None, relative_time_reference: None,
pagination: PaginationState::new(), pagination: PaginationState::new(),
@@ -1141,6 +1147,7 @@ impl PickerState {
cells.clone(), cells.clone(),
self.keymap.pager.clone(), self.keymap.pager.clone(),
self.copy_on_select, self.copy_on_select,
self.mouse_scroll_speed,
); );
if let Overlay::Transcript(view) = &mut overlay { if let Overlay::Transcript(view) = &mut overlay {
view.set_keymap_bindings(&self.keymap); view.set_keymap_bindings(&self.keymap);
@@ -2,16 +2,16 @@
source: tui/src/transcript_view_tests.rs source: tui/src/transcript_view_tests.rs
expression: "frames.join(\"\\n\\n\")" expression: "frames.join(\"\\n\\n\")"
--- ---
row 15
row 16
row 17
row 18
row 19 row 19
row 20 row 20
row 21
row 22
row 23
row 24
row 25
row 26
row 27
row 28
row 29 row 29
row 30 row 30
row 31
row 32
row 33
row 34
+4
View File
@@ -85,6 +85,8 @@ struct VisibleRow {
/// Shared scrolling and interaction state for compact and detailed transcript presentations. /// Shared scrolling and interaction state for compact and detailed transcript presentations.
pub(crate) struct TranscriptView { pub(crate) struct TranscriptView {
pub(crate) mouse_scroll_speed: f64,
pending_mouse_scroll: f64,
pub(crate) copy_on_select: bool, pub(crate) copy_on_select: bool,
pub(crate) primary_selection: bool, pub(crate) primary_selection: bool,
position: Position, position: Position,
@@ -118,6 +120,8 @@ pub(crate) struct TranscriptView {
impl Default for TranscriptView { impl Default for TranscriptView {
fn default() -> Self { fn default() -> Self {
Self { Self {
mouse_scroll_speed: 1.0,
pending_mouse_scroll: 0.0,
copy_on_select: false, copy_on_select: false,
primary_selection: false, primary_selection: false,
position: Position::Latest, position: Position::Latest,
+22 -2
View File
@@ -3,6 +3,7 @@
//! Shift-click extends the existing selection from its original text unit. //! Shift-click extends the existing selection from its original text unit.
//! Optional automatic copying happens only when a nonempty mouse selection is released. //! Optional automatic copying happens only when a nonempty mouse selection is released.
//! Automatic copies retain the selection; explicit copies clear it after confirmed delivery. //! 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 crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
@@ -232,8 +233,27 @@ impl TranscriptView {
selection.pointer = None; selection.pointer = None;
} }
match event.kind { match event.kind {
MouseEventKind::ScrollUp => self.scroll(cells, /*rows*/ -3), MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
MouseEventKind::ScrollDown => self.scroll(cells, /*rows*/ 3), 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 => { MouseEventKind::Down(MouseButton::Right) if inside => {
return self return self
.selected_text(cells) .selected_text(cells)
+107 -1
View File
@@ -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 super::*;
use crate::history_cell::AgentMarkdownCell; use crate::history_cell::AgentMarkdownCell;
use pretty_assertions::assert_eq; 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<Arc<dyn HistoryCell>> = 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<Arc<dyn HistoryCell>> = 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<Arc<dyn HistoryCell>>) { fn transcript(markdown: &str, width: u16) -> (TranscriptView, Vec<Arc<dyn HistoryCell>>) {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new( let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(AgentMarkdownCell::new(
markdown.into(), markdown.into(),
@@ -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