feat: allow opting-out of scroll mode sync (#5532)

* feat: allow opting-out of scroll mode sync

* add pr
This commit is contained in:
Aram Drevekenin
2026-08-25 11:02:57 +02:00
committed by GitHub
parent 75f9a38715
commit a6dbdad8ae
36 changed files with 261 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: kitty image size on startup and clearing with stacked unlisted full framed panes (https://github.com/zellij-org/zellij/pull/5526)
* fix: focus event in remote attach (https://github.com/zellij-org/zellij/pull/5527)
* fix: editing scrollback issue with a fullscreen floating pane (https://github.com/zellij-org/zellij/pull/5528)
* feat: allow opting-out of scroll mode sync (https://github.com/zellij-org/zellij/pull/5532)
## [0.45.0] - 2026-08-20
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
+5
View File
@@ -540,6 +540,11 @@ load_plugins {
//
// mouse_scroll_resize false
// Whether scrolling a pane implicitly enters and exits Scroll mode
// Default: true
//
// scroll_mode_sync false
// Whether to enable mouse hover visual effects (frame highlight and help text)
// Default: true
//
+1
View File
@@ -471,6 +471,7 @@ impl SessionMetaData {
.advanced_mouse_actions
.unwrap_or(true),
mouse_scroll_resize: new_config.options.mouse_scroll_resize.unwrap_or(true),
scroll_mode_sync: new_config.options.scroll_mode_sync.unwrap_or(true),
mouse_hover_effects: new_config.options.mouse_hover_effects.unwrap_or(true),
mouse_hover_tips: new_config.options.mouse_hover_tips.unwrap_or(true),
visual_bell: new_config.options.visual_bell.unwrap_or(true),
+14
View File
@@ -816,6 +816,7 @@ pub enum ScreenInstruction {
default_editor: Option<PathBuf>,
advanced_mouse_actions: bool,
mouse_scroll_resize: bool,
scroll_mode_sync: bool,
mouse_hover_effects: bool,
mouse_hover_tips: bool,
visual_bell: bool,
@@ -1577,6 +1578,7 @@ pub(crate) struct Screen {
osc133_command_selection: bool,
word_separators: String,
mouse_scroll_resize: bool,
scroll_mode_sync: bool,
mouse_hover_effects: bool,
mouse_hover_tips: bool,
visual_bell: bool,
@@ -1707,6 +1709,7 @@ impl Screen {
osc133_command_selection: bool,
word_separators: String,
mouse_scroll_resize: bool,
scroll_mode_sync: bool,
mouse_hover_effects: bool,
mouse_hover_tips: bool,
visual_bell: bool,
@@ -1776,6 +1779,7 @@ impl Screen {
osc133_command_selection,
word_separators,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -6015,6 +6019,9 @@ impl Screen {
// under unlock-first), so a client in another mode (Pane, Tab, Search, ...) is never
// pulled out of it. See #638.
fn sync_scroll_mode_on_focus(&mut self, client_id: ClientId) -> Result<()> {
if !self.scroll_mode_sync {
return Ok(());
}
// base_mode is the default config reloads keep current; .mode is the fallback.
let default_mode = self
.default_mode_info
@@ -6025,6 +6032,7 @@ impl Screen {
}
let current_mode = match self.mode_info.get(&client_id) {
Some(mode_info) => mode_info.mode,
None if self.active_tab_ids.contains_key(&client_id) => default_mode,
None => return Ok(()),
};
let active_pane_is_scrolled = self.active_pane_is_scrolled(client_id);
@@ -6803,6 +6811,7 @@ impl Screen {
default_editor: Option<PathBuf>,
advanced_mouse_actions: bool,
mouse_scroll_resize: bool,
scroll_mode_sync: bool,
mouse_hover_effects: bool,
mouse_hover_tips: bool,
visual_bell: bool,
@@ -6835,6 +6844,7 @@ impl Screen {
self.pane_frame_style = pane_frame_style;
self.advanced_mouse_actions = advanced_mouse_actions;
self.mouse_scroll_resize = mouse_scroll_resize;
self.scroll_mode_sync = scroll_mode_sync;
self.mouse_hover_effects = mouse_hover_effects;
self.mouse_hover_tips = mouse_hover_tips;
self.visual_bell = visual_bell;
@@ -8029,6 +8039,7 @@ pub(crate) fn screen_thread_main(
.clone()
.unwrap_or_else(|| DEFAULT_WORD_SEPARATORS.to_owned());
let mouse_scroll_resize = config_options.mouse_scroll_resize.unwrap_or(true);
let scroll_mode_sync = config_options.scroll_mode_sync.unwrap_or(true);
let mouse_hover_effects = config_options.mouse_hover_effects.unwrap_or(true);
let mouse_hover_tips = config_options.mouse_hover_tips.unwrap_or(true);
let visual_bell = config_options.visual_bell.unwrap_or(true);
@@ -8080,6 +8091,7 @@ pub(crate) fn screen_thread_main(
osc133_command_selection,
word_separators,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -11467,6 +11479,7 @@ pub(crate) fn screen_thread_main(
default_editor,
advanced_mouse_actions,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -11499,6 +11512,7 @@ pub(crate) fn screen_thread_main(
default_editor,
advanced_mouse_actions,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
+132
View File
@@ -385,6 +385,7 @@ fn create_new_screen_with_capture(
true,
DEFAULT_WORD_SEPARATORS.to_owned(),
mouse_scroll_resize,
true,
mouse_hover_effects,
true,
visual_bell,
@@ -5819,6 +5820,7 @@ fn create_new_screen_with_message_capture(
true,
true,
true,
true,
visual_bell,
false, // focus_follows_mouse
false, // mouse_click_through
@@ -8930,6 +8932,7 @@ fn create_new_screen_with_forward_capture(size: Size) -> (Screen, ForwardCapture
true,
true,
true,
true,
visual_bell,
false, // focus_follows_mouse
false, // mouse_click_through
@@ -9782,6 +9785,7 @@ fn create_new_screen_with_theme_capture(size: Size) -> (Screen, ThemeCapture) {
true,
true,
true,
true,
false,
false,
web_server_ip,
@@ -10312,6 +10316,7 @@ fn create_non_mirrored_screen(size: Size) -> Screen {
true,
DEFAULT_WORD_SEPARATORS.to_owned(),
true, // mouse_scroll_resize
true,
true, // mouse_hover_effects
true,
true, // visual_bell
@@ -12492,6 +12497,50 @@ pub fn focus_pane_with_id_syncs_scroll_mode() {
mock_screen.teardown(vec![server_thread, screen_thread]);
}
#[test]
pub fn scrolling_syncs_scroll_mode_for_a_client_that_never_changed_mode() {
let size = Size { cols: 80, rows: 10 };
let mut initial_layout = TiledPaneLayout::default();
initial_layout.children_split_direction = SplitDirection::Vertical;
initial_layout.children = vec![TiledPaneLayout::default(), TiledPaneLayout::default()];
let mut mock_screen = MockScreen::new(size);
mock_screen.config.options.default_mode = Some(InputMode::Locked);
let client_id = mock_screen.main_client_id;
let session_metadata = mock_screen.clone_session_metadata();
let screen_thread = mock_screen.run(Some(initial_layout), vec![]);
let received_server_instructions = Arc::new(Mutex::new(vec![]));
let server_receiver = mock_screen.server_receiver.take().unwrap();
let server_thread = log_actions_in_thread!(
received_server_instructions,
ServerInstruction::KillSession,
server_receiver
);
let mut pane_contents = String::new();
for i in 0..20 {
pane_contents.push_str(&format!("fill pane up with something {}\n\r", i));
}
let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
0,
pane_contents.as_bytes().to_vec(),
));
std::thread::sleep(std::time::Duration::from_millis(100));
send_cli_action_to_server(
&session_metadata,
CliAction::ScrollUp { pane_id: None },
client_id,
);
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(
last_change_mode_for_client(&received_server_instructions, client_id),
Some(InputMode::Scroll),
"a client that never switched modes should still enter Scroll mode when scrolling",
);
mock_screen.teardown(vec![server_thread, screen_thread]);
}
#[test]
pub fn scrolling_the_focused_pane_syncs_scroll_mode() {
let size = Size { cols: 80, rows: 10 };
@@ -12608,6 +12657,89 @@ pub fn scrolling_the_focused_pane_with_the_mouse_syncs_scroll_mode() {
mock_screen.teardown(vec![server_thread, screen_thread]);
}
#[test]
pub fn scrolling_does_not_sync_scroll_mode_when_disabled_in_the_config() {
let size = Size { cols: 80, rows: 10 };
let mut initial_layout = TiledPaneLayout::default();
initial_layout.children_split_direction = SplitDirection::Vertical;
initial_layout.children = vec![TiledPaneLayout::default(), TiledPaneLayout::default()];
let mut mock_screen = MockScreen::new(size);
mock_screen.config.options.scroll_mode_sync = Some(false);
let client_id = mock_screen.main_client_id;
let session_metadata = mock_screen.clone_session_metadata();
let screen_thread = mock_screen.run(Some(initial_layout), vec![]);
let received_server_instructions = Arc::new(Mutex::new(vec![]));
let server_receiver = mock_screen.server_receiver.take().unwrap();
let server_thread = log_actions_in_thread!(
received_server_instructions,
ServerInstruction::KillSession,
server_receiver
);
let mut pane_contents = String::new();
for i in 0..20 {
pane_contents.push_str(&format!("fill pane up with something {}\n\r", i));
}
let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
0,
pane_contents.as_bytes().to_vec(),
));
let _ = mock_screen.to_screen.send(ScreenInstruction::ChangeMode(
InputMode::Normal,
None,
client_id,
None,
));
std::thread::sleep(std::time::Duration::from_millis(100));
received_server_instructions.lock().unwrap().clear();
send_cli_action_to_server(
&session_metadata,
CliAction::ScrollUp { pane_id: None },
client_id,
);
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(
last_change_mode_for_client(&received_server_instructions, client_id),
None,
"scrolling the focused pane up should not change the mode when scroll_mode_sync is false",
);
let _ = mock_screen.to_screen.send(ScreenInstruction::MouseEvent(
MouseEvent::new_scroll_up_event(Position::new(5, 10)),
client_id,
None,
));
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(
last_change_mode_for_client(&received_server_instructions, client_id),
None,
"wheel-scrolling the focused pane should not change the mode when scroll_mode_sync is false",
);
let _ = mock_screen.to_screen.send(ScreenInstruction::ChangeMode(
InputMode::Scroll,
None,
client_id,
None,
));
std::thread::sleep(std::time::Duration::from_millis(100));
received_server_instructions.lock().unwrap().clear();
send_cli_action_to_server(
&session_metadata,
CliAction::ScrollToBottom { pane_id: None },
client_id,
);
std::thread::sleep(std::time::Duration::from_millis(100));
assert_eq!(
last_change_mode_for_client(&received_server_instructions, client_id),
None,
"scrolling back to the bottom should not leave Scroll mode when scroll_mode_sync is false",
);
mock_screen.teardown(vec![server_thread, screen_thread]);
}
#[test]
pub fn scrolling_an_unfocused_pane_does_not_sync_scroll_mode() {
let size = Size { cols: 80, rows: 10 };
+5
View File
@@ -567,6 +567,11 @@ load_plugins {
//
// mouse_scroll_resize false
// Whether scrolling a pane implicitly enters and exits Scroll mode
// Default: true
//
// scroll_mode_sync false
// Whether to enable mouse hover visual effects (frame highlight and help text)
// Default: true
//
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2062,6 +2062,8 @@ pub struct Options {
pub mouse_hover_tips: ::core::option::Option<bool>,
#[prost(string, optional, tag="67")]
pub host_notification_protocol: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag="68")]
pub scroll_mode_sync: ::core::option::Option<bool>,
}
/// Pane-targeting action messages
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1223,6 +1223,7 @@ message Options {
optional bool dangerously_enable_paste_buffer_read = 58;
optional bool mouse_hover_tips = 59;
optional string host_notification_protocol = 67;
optional bool scroll_mode_sync = 68;
}
enum OnForceClose {
+10
View File
@@ -337,6 +337,12 @@ pub struct Options {
#[serde(default)]
pub mouse_scroll_resize: Option<bool>,
/// Whether scrolling a pane implicitly enters (and leaving the scroll implicitly exits) Scroll mode
/// default is true
#[clap(long, value_parser)]
#[serde(default)]
pub scroll_mode_sync: Option<bool>,
/// Whether to enable mouse hover visual effects (frame highlight and help text)
/// default is true
#[clap(long, value_parser)]
@@ -553,6 +559,7 @@ impl Options {
let show_release_notes = other.show_release_notes.or(self.show_release_notes);
let advanced_mouse_actions = other.advanced_mouse_actions.or(self.advanced_mouse_actions);
let mouse_scroll_resize = other.mouse_scroll_resize.or(self.mouse_scroll_resize);
let scroll_mode_sync = other.scroll_mode_sync.or(self.scroll_mode_sync);
let mouse_hover_effects = other.mouse_hover_effects.or(self.mouse_hover_effects);
let mouse_hover_tips = other.mouse_hover_tips.or(self.mouse_hover_tips);
let visual_bell = other.visual_bell.or(self.visual_bell);
@@ -630,6 +637,7 @@ impl Options {
show_release_notes,
advanced_mouse_actions,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -720,6 +728,7 @@ impl Options {
let show_release_notes = other.show_release_notes.or(self.show_release_notes);
let advanced_mouse_actions = other.advanced_mouse_actions.or(self.advanced_mouse_actions);
let mouse_scroll_resize = other.mouse_scroll_resize.or(self.mouse_scroll_resize);
let scroll_mode_sync = other.scroll_mode_sync.or(self.scroll_mode_sync);
let mouse_hover_effects = other.mouse_hover_effects.or(self.mouse_hover_effects);
let mouse_hover_tips = other.mouse_hover_tips.or(self.mouse_hover_tips);
let visual_bell = other.visual_bell.or(self.visual_bell);
@@ -797,6 +806,7 @@ impl Options {
show_release_notes,
advanced_mouse_actions,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -945,6 +945,7 @@ impl From<crate::input::options::Options>
show_release_notes: options.show_release_notes,
advanced_mouse_actions: options.advanced_mouse_actions,
mouse_scroll_resize: options.mouse_scroll_resize,
scroll_mode_sync: options.scroll_mode_sync,
mouse_hover_effects: options.mouse_hover_effects,
mouse_hover_tips: options.mouse_hover_tips,
web_server_ip: options.web_server_ip.map(|ip| ip.to_string()),
@@ -1075,6 +1076,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
show_release_notes: options.show_release_notes,
advanced_mouse_actions: options.advanced_mouse_actions,
mouse_scroll_resize: options.mouse_scroll_resize,
scroll_mode_sync: options.scroll_mode_sync,
mouse_hover_effects: options.mouse_hover_effects,
mouse_hover_tips: options.mouse_hover_tips,
web_server_ip: options
@@ -492,6 +492,7 @@ fn test_client_messages() {
show_release_notes: Some(true),
advanced_mouse_actions: Some(true),
mouse_scroll_resize: Some(true),
scroll_mode_sync: Some(true),
web_server_ip: Some("1.1.1.1".parse().unwrap()),
web_server_port: Some(8080),
web_server_cert: Some(PathBuf::from("web_server_cert")),
+70
View File
@@ -2886,6 +2886,9 @@ impl Options {
let mouse_scroll_resize =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "mouse_scroll_resize")
.map(|(v, _)| v);
let scroll_mode_sync =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "scroll_mode_sync")
.map(|(v, _)| v);
let mouse_hover_effects =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "mouse_hover_effects")
.map(|(v, _)| v);
@@ -3019,6 +3022,7 @@ impl Options {
show_release_notes,
advanced_mouse_actions,
mouse_scroll_resize,
scroll_mode_sync,
mouse_hover_effects,
mouse_hover_tips,
visual_bell,
@@ -4308,6 +4312,33 @@ impl Options {
None
}
}
fn scroll_mode_sync_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = format!(
"{}\n{}\n{}",
" ",
"// Whether scrolling a pane implicitly enters and exits Scroll mode",
"// default is true",
);
let create_node = |node_value: bool| -> KdlNode {
let mut node = KdlNode::new("scroll_mode_sync");
node.push(KdlValue::Bool(node_value));
node
};
if let Some(scroll_mode_sync) = self.scroll_mode_sync {
let mut node = create_node(scroll_mode_sync);
if add_comments {
node.set_leading(format!("{}\n", comment_text));
}
Some(node)
} else if add_comments {
let mut node = create_node(false);
node.set_leading(format!("{}\n// ", comment_text));
Some(node)
} else {
None
}
}
fn mouse_hover_tips_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = format!(
"{}\n{}\n{}",
@@ -4849,6 +4880,9 @@ impl Options {
if let Some(mouse_scroll_resize) = self.mouse_scroll_resize_to_kdl(add_comments) {
nodes.push(mouse_scroll_resize);
}
if let Some(scroll_mode_sync) = self.scroll_mode_sync_to_kdl(add_comments) {
nodes.push(scroll_mode_sync);
}
if let Some(mouse_hover_effects) = self.mouse_hover_effects_to_kdl(add_comments) {
nodes.push(mouse_hover_effects);
}
@@ -7657,6 +7691,42 @@ fn selection_options_default_to_none_when_unspecified() {
assert_eq!(deserialized.word_separators, None);
}
#[test]
fn scroll_mode_sync_from_kdl() {
let fake_config = r##"
scroll_mode_sync false
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
assert_eq!(deserialized.scroll_mode_sync, Some(false));
let empty_document: KdlDocument = "".parse().unwrap();
let deserialized_empty = Options::from_kdl(&empty_document).unwrap();
assert_eq!(
deserialized_empty.scroll_mode_sync, None,
"an unspecified scroll_mode_sync stays None so the default applies"
);
}
#[test]
fn scroll_mode_sync_round_trips_through_kdl() {
let fake_config = r##"
scroll_mode_sync false
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
let mut serialized = Options::to_kdl(&deserialized, false);
let mut fake_document = KdlDocument::new();
fake_document.nodes_mut().append(&mut serialized);
let deserialized_from_serialized =
Options::from_kdl(&fake_document.to_string().parse::<KdlDocument>().unwrap()).unwrap();
assert_eq!(
deserialized_from_serialized.scroll_mode_sync,
Some(false),
"scroll_mode_sync survives a serialize/parse round trip"
);
}
#[test]
fn config_options_to_string() {
let fake_config = r##"
@@ -574,6 +574,10 @@ web_client {
// default is true
// mouse_scroll_resize false
// Whether scrolling a pane implicitly enters and exits Scroll mode
// default is true
// scroll_mode_sync false
// Whether to enable mouse hover visual effects (frame highlight and help text)
// default is true
// mouse_hover_effects false
@@ -284,6 +284,10 @@ web_sharing "disabled"
// default is true
// mouse_scroll_resize false
// Whether scrolling a pane implicitly enters and exits Scroll mode
// default is true
// scroll_mode_sync false
// Whether to enable mouse hover visual effects (frame highlight and help text)
// default is true
// mouse_hover_effects false
@@ -45,6 +45,7 @@ Options {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -45,6 +45,7 @@ Options {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -43,6 +43,7 @@ Options {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -6135,6 +6135,7 @@ Config {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -6135,6 +6135,7 @@ Config {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -130,6 +130,7 @@ Config {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -45,6 +45,7 @@ Options {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -6135,6 +6135,7 @@ Config {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,
@@ -6135,6 +6135,7 @@ Config {
show_release_notes: None,
advanced_mouse_actions: None,
mouse_scroll_resize: None,
scroll_mode_sync: None,
mouse_hover_effects: None,
mouse_hover_tips: None,
visual_bell: None,