diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b316b34d..cad2638c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +* fix(compact-bar): wheel events over the one-row chrome target the exact focused terminal pane at its cursor (with a content-center fallback) instead of switching tabs, preserving native mouse-aware TUI scrolling, alternate-screen fallback and ordinary scrollback without coordinate hit-test races; the compact bar clamps large trackpad deltas to the shared 100-line limit, the plugin command requires stdin permission and rejects larger third-party requests, and the host revalidates stale cursor positions against the pane's current dimensions after resize * fix(chrome): Composer and Quick cmd remain clickable, label-only actions in the top bar while their `⌘E` / `⇧⌘.` teaching lane stays permanently in the bottom bar across input modes; the theme glyph keeps a one-column trailing inset in borderless hosts * fix(server): a starting server no longer unlinks and re-binds a session socket that a live server still owns — it probes the path first and refuses to start when someone is listening, even if that server is too busy to answer a health probe; only missing, stale or non-socket paths are cleaned up. A server already running a session likewise rejects a second new-session request instead of re-initializing over live state * fix(chrome): the chrome a client had visible is parked when that client detaches, so a server with nobody attached stops refreshing the cross-session list once a second; attaching re-activates it and rebuilds the list on the spot diff --git a/default-plugins/compact-bar/src/main.rs b/default-plugins/compact-bar/src/main.rs index ddd9cd16f..ea3bcfb77 100644 --- a/default-plugins/compact-bar/src/main.rs +++ b/default-plugins/compact-bar/src/main.rs @@ -5,7 +5,6 @@ mod line; mod tab; mod tooltip; -use std::cmp::{max, min}; use std::collections::{BTreeMap, BTreeSet}; use std::convert::TryInto; use std::path::PathBuf; @@ -444,8 +443,8 @@ impl State { match mouse_event { Mouse::LeftClick(_, col) => self.handle_tab_click(col), - Mouse::ScrollUp(_) => self.scroll_tab_up(), - Mouse::ScrollDown(_) => self.scroll_tab_down(), + Mouse::ScrollUp(lines) => self.forward_scroll_to_focused_pane(true, lines), + Mouse::ScrollDown(lines) => self.forward_scroll_to_focused_pane(false, lines), _ => {}, } } @@ -635,10 +634,72 @@ impl State { changed } - fn scroll_tab_up(&self) { - let next_tab = min(self.active_tab_idx + 1, self.tabs.len()); - switch_tab_to(next_tab as u32); + fn forward_scroll_to_focused_pane(&self, scroll_up: bool, lines: usize) { + let Ok((_, focused_pane_id)) = get_focused_pane_info() else { + return; + }; + let Some(focused_pane) = get_pane_info(focused_pane_id) else { + return; + }; + let Some((pane_id, position)) = + focused_terminal_scroll_target(focused_pane_id, &focused_pane) + else { + return; + }; + let lines = bounded_mouse_scroll_lines(lines); + if scroll_up { + mouse_scroll_up_in_pane_id(pane_id, position, lines); + } else { + mouse_scroll_down_in_pane_id(pane_id, position, lines); + } + } +} + +fn bounded_mouse_scroll_lines(lines: usize) -> usize { + lines.min(plugin_api::plugin_command::MAX_MOUSE_SCROLL_LINES_IN_PANE_ID) +} + +fn focused_terminal_scroll_target( + focused_pane_id: PaneId, + focused_pane: &PaneInfo, +) -> Option<(PaneId, Position)> { + let pane_id = if focused_pane.is_plugin { + PaneId::Plugin(focused_pane.id) + } else { + PaneId::Terminal(focused_pane.id) + }; + if focused_pane.is_plugin || pane_id != focused_pane_id { + return None; + } + if focused_pane.pane_content_rows == 0 || focused_pane.pane_content_columns == 0 { + return None; } + + let content_offset_column = focused_pane + .pane_content_x + .saturating_sub(focused_pane.pane_x); + let content_offset_line = focused_pane + .pane_content_y + .saturating_sub(focused_pane.pane_y); + let (column, line) = focused_pane + .cursor_coordinates_in_pane + .and_then(|(column, line)| { + Some(( + column.checked_sub(content_offset_column)?, + line.checked_sub(content_offset_line)?, + )) + }) + .filter(|(column, line)| { + *column < focused_pane.pane_content_columns && *line < focused_pane.pane_content_rows + }) + .unwrap_or(( + focused_pane.pane_content_columns / 2, + focused_pane.pane_content_rows / 2, + )); + Some(( + focused_pane_id, + Position::new(line.try_into().ok()?, column.try_into().ok()?), + )) } /// Quick cmd mini console: shallow, wide, upper-center — non-ephemeral @@ -704,11 +765,6 @@ fn open_composer() { } impl State { - fn scroll_tab_down(&self) { - let prev_tab = max(self.active_tab_idx.saturating_sub(1), 1); - switch_tab_to(prev_tab as u32); - } - fn clear_clipboard_state(&mut self) { self.text_copy_destination = None; self.display_system_clipboard_failure = false; @@ -971,6 +1027,73 @@ mod transient_dimension_guard_tests { assert!(!state.quick_cmd_message_targets_active_bar(&public_message)); } + #[test] + fn wheel_actions_target_the_focused_terminal_cursor() { + let focused_terminal = PaneInfo { + is_focused: true, + pane_x: 23, + pane_y: 1, + pane_content_x: 24, + pane_content_y: 2, + pane_content_columns: 80, + pane_content_rows: 20, + cursor_coordinates_in_pane: Some((8, 4)), + ..Default::default() + }; + let target = + focused_terminal_scroll_target(PaneId::Terminal(0), &focused_terminal).unwrap(); + assert_eq!(target, (PaneId::Terminal(0), Position::new(3, 7))); + } + + #[test] + fn wheel_actions_fall_back_to_the_content_center() { + let focused_terminal = PaneInfo { + id: 4, + pane_content_x: 24, + pane_content_y: 2, + pane_content_columns: 80, + pane_content_rows: 20, + ..Default::default() + }; + + let target = + focused_terminal_scroll_target(PaneId::Terminal(4), &focused_terminal).unwrap(); + assert_eq!(target, (PaneId::Terminal(4), Position::new(10, 40))); + } + + #[test] + fn wheel_forwarding_bounds_large_trackpad_deltas() { + assert_eq!(bounded_mouse_scroll_lines(3), 3); + assert_eq!(bounded_mouse_scroll_lines(100), 100); + assert_eq!(bounded_mouse_scroll_lines(usize::MAX), 100); + } + + #[test] + fn wheel_forwarding_ignores_plugin_only_and_empty_content_surfaces() { + let plugin_only = PaneInfo { + is_focused: true, + is_plugin: true, + pane_content_columns: 80, + pane_content_rows: 20, + ..Default::default() + }; + assert_eq!( + focused_terminal_scroll_target(PaneId::Plugin(0), &plugin_only), + None + ); + + let empty_terminal = PaneInfo { + is_focused: true, + pane_content_columns: 0, + pane_content_rows: 20, + ..Default::default() + }; + assert_eq!( + focused_terminal_scroll_target(PaneId::Terminal(0), &empty_terminal), + None + ); + } + #[test] fn theme_command_result_tracks_dark_and_light_without_accepting_noise() { let mut state = State::default(); diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index cc5f7d6c2..277b1ec9d 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -43,6 +43,7 @@ use zellij_utils::data::{ use zellij_utils::home::default_layout_dir; use zellij_utils::input::permission::PermissionCache; use zellij_utils::ipc::{ClientToServerMsg, IpcSenderWithContext}; +use zellij_utils::position::Position; use zellij_utils::sessions::generate_random_name as generate_random_name_impl; #[cfg(feature = "web_server_capability")] use zellij_utils::web_authentication_tokens::{ @@ -517,6 +518,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) { PluginCommand::ScrollDownInPaneId(pane_id) => { scroll_down_in_pane_id(env, pane_id.into()) }, + PluginCommand::MouseScrollUpInPaneId(pane_id, position, lines) => { + mouse_scroll_up_in_pane_id(env, pane_id.into(), position, lines) + }, + PluginCommand::MouseScrollDownInPaneId(pane_id, position, lines) => { + mouse_scroll_down_in_pane_id(env, pane_id.into(), position, lines) + }, PluginCommand::ScrollToTopInPaneId(pane_id) => { scroll_to_top_in_pane_id(env, pane_id.into()) }, @@ -4689,6 +4696,33 @@ fn scroll_down_in_pane_id(env: &PluginEnv, pane_id: PaneId) { .send_to_screen(ScreenInstruction::ScrollDownInPaneId(pane_id)); } +fn mouse_scroll_up_in_pane_id(env: &PluginEnv, pane_id: PaneId, position: Position, lines: usize) { + let _ = env + .senders + .send_to_screen(ScreenInstruction::MouseScrollUpInPaneId( + pane_id, + position, + lines, + env.client_id, + )); +} + +fn mouse_scroll_down_in_pane_id( + env: &PluginEnv, + pane_id: PaneId, + position: Position, + lines: usize, +) { + let _ = env + .senders + .send_to_screen(ScreenInstruction::MouseScrollDownInPaneId( + pane_id, + position, + lines, + env.client_id, + )); +} + fn scroll_to_top_in_pane_id(env: &PluginEnv, pane_id: PaneId) { let _ = env .senders @@ -5355,7 +5389,9 @@ fn check_command_permission( PluginCommand::Write(..) | PluginCommand::WriteChars(..) | PluginCommand::WriteToPaneId(..) - | PluginCommand::WriteCharsToPaneId(..) => PermissionType::WriteToStdin, + | PluginCommand::WriteCharsToPaneId(..) + | PluginCommand::MouseScrollUpInPaneId(..) + | PluginCommand::MouseScrollDownInPaneId(..) => PermissionType::WriteToStdin, PluginCommand::CopyToClipboard(..) => PermissionType::WriteToClipboard, PluginCommand::SwitchTabTo(..) | PluginCommand::SwitchToMode(..) diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index d477d7f59..97f6b5402 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -977,6 +977,8 @@ pub enum ScreenInstruction { ClearScreenForPaneId(PaneId), ScrollUpInPaneId(PaneId), ScrollDownInPaneId(PaneId), + MouseScrollUpInPaneId(PaneId, Position, usize, ClientId), + MouseScrollDownInPaneId(PaneId, Position, usize, ClientId), ScrollToTopInPaneId(PaneId), ScrollToBottomInPaneId(PaneId), PageScrollUpInPaneId(PaneId), @@ -1329,6 +1331,10 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::ClearScreenForPaneId(..) => ScreenContext::ClearScreenForPaneId, ScreenInstruction::ScrollUpInPaneId(..) => ScreenContext::ScrollUpInPaneId, ScreenInstruction::ScrollDownInPaneId(..) => ScreenContext::ScrollDownInPaneId, + ScreenInstruction::MouseScrollUpInPaneId(..) => ScreenContext::MouseScrollUpInPaneId, + ScreenInstruction::MouseScrollDownInPaneId(..) => { + ScreenContext::MouseScrollDownInPaneId + }, ScreenInstruction::ScrollToTopInPaneId(..) => ScreenContext::ScrollToTopInPaneId, ScreenInstruction::ScrollToBottomInPaneId(..) => ScreenContext::ScrollToBottomInPaneId, ScreenInstruction::PageScrollUpInPaneId(..) => ScreenContext::PageScrollUpInPaneId, @@ -15000,6 +15006,26 @@ pub(crate) fn screen_thread_main(params: ScreenThreadParams) -> Result<()> { } screen.render(None)?; }, + ScreenInstruction::MouseScrollUpInPaneId(pane_id, position, lines, client_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.handle_scrollwheel_up_in_pane(pane_id, &position, lines, client_id)?; + break; + } + } + screen.render(None)?; + }, + ScreenInstruction::MouseScrollDownInPaneId(pane_id, position, lines, client_id) => { + let all_tabs = screen.get_tabs_mut(); + for tab in all_tabs.values_mut() { + if tab.has_pane_with_pid(&pane_id) { + tab.handle_scrollwheel_down_in_pane(pane_id, &position, lines, client_id)?; + break; + } + } + screen.render(None)?; + }, ScreenInstruction::ScrollToTopInPaneId(pane_id) => { let all_tabs = screen.get_tabs_mut(); for tab in all_tabs.values_mut() { diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 070ba83e5..66f452e4f 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -5795,6 +5795,22 @@ impl Tab { MouseHandler::handle_scrollwheel_up(self, point, lines, client_id) } + pub fn handle_scrollwheel_up_in_pane( + &mut self, + pane_id: PaneId, + relative_position: &Position, + lines: usize, + client_id: ClientId, + ) -> Result<()> { + MouseHandler::handle_scrollwheel_up_in_pane( + self, + pane_id, + relative_position, + lines, + client_id, + ) + } + pub fn handle_scrollwheel_down( &mut self, point: &Position, @@ -5804,6 +5820,22 @@ impl Tab { MouseHandler::handle_scrollwheel_down(self, point, lines, client_id) } + pub fn handle_scrollwheel_down_in_pane( + &mut self, + pane_id: PaneId, + relative_position: &Position, + lines: usize, + client_id: ClientId, + ) -> Result<()> { + MouseHandler::handle_scrollwheel_down_in_pane( + self, + pane_id, + relative_position, + lines, + client_id, + ) + } + fn get_pane_id_at( &mut self, point: &Position, diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index 2890e1f16..a6803c562 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -18,6 +18,24 @@ fn plugin_hover_leave_event() -> MouseEvent { MouseEvent::new_buttonless_motion(Position::new(-1, 0)) } +fn bounded_content_position(pane: &dyn Pane, requested_position: &Position) -> Option { + let content_rows = pane.get_content_rows(); + let content_columns = pane.get_content_columns(); + if content_rows == 0 || content_columns == 0 { + return None; + } + + let mut bounded_position = *requested_position; + let last_content_row = isize::try_from(content_rows.saturating_sub(1)).unwrap_or(isize::MAX); + bounded_position.change_line(requested_position.line().clamp(0, last_content_row)); + bounded_position.change_column( + requested_position + .column() + .min(content_columns.saturating_sub(1)), + ); + Some(bounded_position) +} + /// Pure UpdateHover policy — no Tab, no focus steal. /// /// `focus_follows_mouse` is intentionally out of this path: hover highlights @@ -1723,6 +1741,106 @@ impl MouseHandler { Ok(MouseEffect::default()) } + pub(crate) fn handle_scrollwheel_up_in_pane( + tab: &mut Tab, + pane_id: PaneId, + relative_position: &Position, + lines: usize, + client_id: ClientId, + ) -> Result<()> { + let err_context = || { + format!("failed to handle scrollwheel up in pane {pane_id:?} at {relative_position:?}") + }; + let Some(pane) = tab.get_pane_with_id_mut(pane_id) else { + return Ok(()); + }; + let Some(relative_position) = bounded_content_position(pane.as_ref(), relative_position) + else { + return Ok(()); + }; + let (input_bytes, repetitions) = + if let Some(mouse_event) = pane.mouse_scroll_up(&relative_position) { + (Some(mouse_event.into_bytes()), 1) + } else if pane.is_alternate_mode_active() { + (Some("\u{1b}[A".as_bytes().to_owned()), lines) + } else { + pane.scroll_up(lines, client_id); + (None, 0) + }; + + if let Some(input_bytes) = input_bytes { + for _ in 0..repetitions { + tab.write_to_pane_id( + &None, + input_bytes.clone(), + false, + pane_id, + Some(client_id), + None, + ) + .with_context(err_context)?; + } + } + Ok(()) + } + + pub(crate) fn handle_scrollwheel_down_in_pane( + tab: &mut Tab, + pane_id: PaneId, + relative_position: &Position, + lines: usize, + client_id: ClientId, + ) -> Result<()> { + let err_context = || { + format!( + "failed to handle scrollwheel down in pane {pane_id:?} at {relative_position:?}" + ) + }; + let Some(pane) = tab.get_pane_with_id_mut(pane_id) else { + return Ok(()); + }; + let Some(relative_position) = bounded_content_position(pane.as_ref(), relative_position) + else { + return Ok(()); + }; + let (input_bytes, repetitions, pending_vte_pane_id) = + if let Some(mouse_event) = pane.mouse_scroll_down(&relative_position) { + (Some(mouse_event.into_bytes()), 1, None) + } else if pane.is_alternate_mode_active() { + (Some("\u{1b}[B".as_bytes().to_owned()), lines, None) + } else { + pane.scroll_down(lines, client_id); + let pending_vte_pane_id = if !pane.is_scrolled() { + match pane.pid() { + PaneId::Terminal(pid) => Some(pid), + PaneId::Plugin(_) => None, + } + } else { + None + }; + (None, 0, pending_vte_pane_id) + }; + + if let Some(input_bytes) = input_bytes { + for _ in 0..repetitions { + tab.write_to_pane_id( + &None, + input_bytes.clone(), + false, + pane_id, + Some(client_id), + None, + ) + .with_context(err_context)?; + } + } + if let Some(pid) = pending_vte_pane_id { + tab.process_pending_vte_events(pid) + .with_context(err_context)?; + } + Ok(()) + } + fn handle_resize_scroll_up( tab: &mut Tab, pane_id: PaneId, diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs index 3ee71c121..f79c69736 100644 --- a/zellij-server/src/tab/unit/tab_integration_tests.rs +++ b/zellij-server/src/tab/unit/tab_integration_tests.rs @@ -5034,7 +5034,6 @@ fn pane_faux_scrolling_in_alternate_mode() { .unwrap(); tab.handle_scrollwheel_down(&Position::new(1, 1), lines_to_scroll, client_id) .unwrap(); - tab.handle_pty_bytes(1, enable_alternate_screen.as_bytes().to_vec()) .unwrap(); // CSI A * lines_to_scroll, CSI B * lines_to_scroll @@ -5050,6 +5049,20 @@ fn pane_faux_scrolling_in_alternate_mode() { .unwrap(); tab.handle_scrollwheel_down(&Position::new(1, 1), lines_to_scroll, client_id) .unwrap(); + tab.handle_scrollwheel_up_in_pane( + PaneId::Terminal(1), + &Position::new(1, 1), + lines_to_scroll, + client_id, + ) + .unwrap(); + tab.handle_scrollwheel_down_in_pane( + PaneId::Terminal(1), + &Position::new(1, 1), + lines_to_scroll, + client_id, + ) + .unwrap(); pty_instruction_bus.exit(); @@ -5058,10 +5071,80 @@ fn pane_faux_scrolling_in_alternate_mode() { expected.append(&mut vec!["\u{1b}[B"; lines_to_scroll]); expected.append(&mut vec!["\u{1b}OA"; lines_to_scroll]); expected.append(&mut vec!["\u{1b}OB"; lines_to_scroll]); + expected.append(&mut vec!["\u{1b}OA"; lines_to_scroll]); + expected.append(&mut vec!["\u{1b}OB"; lines_to_scroll]); assert_eq!(pty_instruction_bus.clone_output(), expected); } +#[test] +fn mouse_scroll_in_pane_id_preserves_the_exact_local_position() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut pty_instruction_bus = MockPtyInstructionBus::new(); + let mut tab = create_new_tab_with_mock_pty_writer( + size, + ModeInfo::default(), + pty_instruction_bus.pty_write_sender(), + ); + pty_instruction_bus.start(); + + tab.handle_pty_bytes(1, b"\x1b[?1002;1006h".to_vec()) + .unwrap(); + tab.handle_scrollwheel_up_in_pane(PaneId::Terminal(1), &Position::new(3, 7), 2, client_id) + .unwrap(); + tab.handle_scrollwheel_down_in_pane(PaneId::Terminal(1), &Position::new(3, 7), 2, client_id) + .unwrap(); + + pty_instruction_bus.exit(); + + assert_eq!( + pty_instruction_bus.clone_output(), + vec!["\x1b[<64;8;4M", "\x1b[<65;8;4M"] + ); +} + +#[test] +fn mouse_scroll_in_pane_id_bounds_stale_positions_to_current_content() { + let size = Size { + cols: 121, + rows: 20, + }; + let client_id = 1; + let mut pty_instruction_bus = MockPtyInstructionBus::new(); + let mut tab = create_new_tab_with_mock_pty_writer( + size, + ModeInfo::default(), + pty_instruction_bus.pty_write_sender(), + ); + pty_instruction_bus.start(); + + tab.handle_pty_bytes(1, b"\x1b[?1002;1006h".to_vec()) + .unwrap(); + let (content_columns, content_rows) = { + let pane = tab.get_pane_with_id(PaneId::Terminal(1)).unwrap(); + (pane.get_content_columns(), pane.get_content_rows()) + }; + let stale_position = Position::new(i32::MAX, u16::MAX); + tab.handle_scrollwheel_up_in_pane(PaneId::Terminal(1), &stale_position, 2, client_id) + .unwrap(); + tab.handle_scrollwheel_down_in_pane(PaneId::Terminal(1), &stale_position, 2, client_id) + .unwrap(); + + pty_instruction_bus.exit(); + + assert_eq!( + pty_instruction_bus.clone_output(), + vec![ + format!("\x1b[<64;{content_columns};{content_rows}M"), + format!("\x1b[<65;{content_columns};{content_rows}M"), + ] + ); +} + #[test] fn move_pane_focus_sends_tty_csi_event() { let size = Size { diff --git a/zellij-tile/src/prelude.rs b/zellij-tile/src/prelude.rs index 21d1317f6..ccc1ade97 100644 --- a/zellij-tile/src/prelude.rs +++ b/zellij-tile/src/prelude.rs @@ -4,3 +4,4 @@ pub use zellij_utils::consts::VERSION; pub use zellij_utils::data::*; pub use zellij_utils::errors::prelude::*; pub use zellij_utils::input::actions; +pub use zellij_utils::position::Position; diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs index 1dcbf8ab4..9499240ae 100644 --- a/zellij-tile/src/shim.rs +++ b/zellij-tile/src/shim.rs @@ -42,6 +42,7 @@ use zellij_utils::plugin_api::plugin_command::{ get_pane_running_command_response, get_session_list_response, parse_layout_response, }; use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion}; +use zellij_utils::position::Position; pub use super::ui_components::*; pub use prost::{self, *}; @@ -2262,6 +2263,24 @@ pub fn scroll_down_in_pane_id(pane_id: PaneId) { unsafe { host_run_plugin_command() }; } +/// Send a mouse-wheel-up event to an exact pane at a position relative to its content. +/// A single command can request at most 100 lines. +pub fn mouse_scroll_up_in_pane_id(pane_id: PaneId, position: Position, lines: usize) { + let plugin_command = PluginCommand::MouseScrollUpInPaneId(pane_id, position, lines); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + +/// Send a mouse-wheel-down event to an exact pane at a position relative to its content. +/// A single command can request at most 100 lines. +pub fn mouse_scroll_down_in_pane_id(pane_id: PaneId, position: Position, lines: usize) { + let plugin_command = PluginCommand::MouseScrollDownInPaneId(pane_id, position, lines); + let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap(); + object_to_stdout(&protobuf_plugin_command.encode_to_vec()); + unsafe { host_run_plugin_command() }; +} + /// Scroll the specified pane all the way to the top of the scrollbuffer pub fn scroll_to_top_in_pane_id(pane_id: PaneId) { let plugin_command = PluginCommand::ScrollToTopInPaneId(pane_id); diff --git a/zellij-utils/assets/plugins/SHA256SUMS b/zellij-utils/assets/plugins/SHA256SUMS index 3de9b587a..2e7f99043 100644 --- a/zellij-utils/assets/plugins/SHA256SUMS +++ b/zellij-utils/assets/plugins/SHA256SUMS @@ -1,14 +1,14 @@ -86ed8be69142ad22c60c343f39cad4f7e59120dab7b0cd83cc2878f4ac0aa1b6 about.wasm -0de0879260c8d7a63e17753e10d39a5cb5a2b366ab9f6b17383d1cdc68937d8f compact-bar.wasm -98288ca4d5d62b95f0a890552b2dfb62f38462ecb48b15e50cd33b8a62945fae configuration.wasm -54d8571c8913e73f812b89bc208bd8272b903a0e3cda39b5e7f9c8d144a00f4e fixture-plugin-for-tests.wasm -689c3286ea9e616020283ddc328a408b831930201658b5f8a53e01c9ec787376 layout-manager.wasm -726e3833656301235729db179884bf71a983993b6d7ed05bbd26c89d0ba9fc31 link.wasm -797089ea4dcba8f54bbc1fe70c36de4ce14c5bb56aa906c1c840facc18a7f644 multiple-select.wasm -c1f637d73aad5a44eba5b0c3b891efa4e79c63bc09af0bc4eab8d5a20cad455f plugin-manager.wasm -47e51cc81ab6577c12328b3c4e368d404b4a0ab7c565fa546ba1013d19e2c55b session-manager.wasm -6edf6dd4d079bb747d8cf19b7b7f99ee62245d1471b2955b9d870fccb1c1b9eb share.wasm -d2deec5e5e2b1ffa9eb3654ca283e426182674cf49539778ebd28b8da2aaa4af status-bar.wasm -980d55d5cd679ddea39e24a6869d810ee40a22111ad3fa830b2d3ece837619af strider.wasm -fdb8b845f5ce7885d0a3fd575bf205455fcb1f26d540248f685b4de0df00a09c tab-bar.wasm -c388e71b441821fed0ca18dfc73399655607f10c185b97925031ef1830d286d5 vc-tab-title.wasm +75c0c0302271174bb425ec10db846b059f14aa40d51eb37a0a5d002246102112 about.wasm +2e44b06f4b59ac705a0b8874f0413db2ce944d1ff05c5f2ecf235c14fd19bf25 compact-bar.wasm +52b1304e52a74f2800b9ab91c03f2fbf6077483ce71a3b1044bfdd8239c41e87 configuration.wasm +d69368a9c5ae1e3637c77e3b6a88064153ecbfb5632e60833011d2d895cef4d6 fixture-plugin-for-tests.wasm +cb57f047f0991cf122ac7c0793c2f5c319e6cc26cd2f5ed02ed659ef82c3b31d layout-manager.wasm +c2ff46f0b8f16b91bf8371f24f90ebe52e5d263dfc52bf136ea440700d0ab1a4 link.wasm +bf419177c6bce8fac11c41de6711f3da6c8de4cdc7c8a23eb7c1ddff655734d3 multiple-select.wasm +01a52ae345d7535018ac8edcd84d4257283532608f73b9e2f2aeaedba3f7d6ad plugin-manager.wasm +ded5faa4f69bd90a17278b9913246c22d4ce10e1930c9e7a958501a1c79d6986 session-manager.wasm +2edeedc639050bfac751563c64dcf74a51855a79553139e41a8c2ae6e01f3f78 share.wasm +fb5005a5e24ba7258d6bc10475c6723052190a718f427e2e54eae66e79ae1632 status-bar.wasm +f45d2978ecdbc9ea6f6e2fe018e2214e6d83931a470f93568db4f4a8b79effa0 strider.wasm +79bf77025b69b887678ced4ff1fae5cd5f48f5d1c8dd91634b28c9844d1dee78 tab-bar.wasm +69a29a02ba0aaafa40b6913550e5c0eb1068d7ebdf4b1fc7a631edd03b3bec1c vc-tab-title.wasm diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm index e7a0eb9d4..210d36236 100755 Binary files a/zellij-utils/assets/plugins/about.wasm and b/zellij-utils/assets/plugins/about.wasm differ diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm index e86077e41..4eefc830c 100755 Binary files a/zellij-utils/assets/plugins/compact-bar.wasm and b/zellij-utils/assets/plugins/compact-bar.wasm differ diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm index ad9be2f47..bf5f11e96 100755 Binary files a/zellij-utils/assets/plugins/configuration.wasm and b/zellij-utils/assets/plugins/configuration.wasm differ diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm index a18511a9d..bdd7bded0 100755 Binary files a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm and b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm differ diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm index 199425579..f7a19eade 100755 Binary files a/zellij-utils/assets/plugins/layout-manager.wasm and b/zellij-utils/assets/plugins/layout-manager.wasm differ diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm index 1103cb240..dd6a4199c 100755 Binary files a/zellij-utils/assets/plugins/link.wasm and b/zellij-utils/assets/plugins/link.wasm differ diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm index e66fadded..52c71e391 100755 Binary files a/zellij-utils/assets/plugins/multiple-select.wasm and b/zellij-utils/assets/plugins/multiple-select.wasm differ diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm index c81e5cc73..37d7fcd82 100755 Binary files a/zellij-utils/assets/plugins/plugin-manager.wasm and b/zellij-utils/assets/plugins/plugin-manager.wasm differ diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm index ab22f8a86..10cfc0b74 100755 Binary files a/zellij-utils/assets/plugins/session-manager.wasm and b/zellij-utils/assets/plugins/session-manager.wasm differ diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm index c3deaf312..8a9efd262 100755 Binary files a/zellij-utils/assets/plugins/share.wasm and b/zellij-utils/assets/plugins/share.wasm differ diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm index ce15624fe..37c58b52c 100755 Binary files a/zellij-utils/assets/plugins/status-bar.wasm and b/zellij-utils/assets/plugins/status-bar.wasm differ diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm index 5b5edefc3..97ccbecb5 100755 Binary files a/zellij-utils/assets/plugins/strider.wasm and b/zellij-utils/assets/plugins/strider.wasm differ diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm index cd6ff78c2..80a5f1f9b 100755 Binary files a/zellij-utils/assets/plugins/tab-bar.wasm and b/zellij-utils/assets/plugins/tab-bar.wasm differ diff --git a/zellij-utils/assets/plugins/vc-tab-title.wasm b/zellij-utils/assets/plugins/vc-tab-title.wasm index 17b565772..54a1a0a0c 100755 Binary files a/zellij-utils/assets/plugins/vc-tab-title.wasm and b/zellij-utils/assets/plugins/vc-tab-title.wasm differ diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs index 2c9ad7fe1..f0b40f854 100644 --- a/zellij-utils/assets/prost/api.plugin_command.rs +++ b/zellij-utils/assets/prost/api.plugin_command.rs @@ -3,7 +3,7 @@ pub struct PluginCommand { #[prost(enumeration="CommandName", tag="1")] pub name: i32, - #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163")] + #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165")] pub payload: ::core::option::Option, } /// Nested message and enum types in `PluginCommand`. @@ -309,6 +309,10 @@ pub mod plugin_command { KillSessionsAndReplyPayload(super::KillSessionsPayload), #[prost(string, tag="163")] DeleteDeadSessionAndReplyPayload(::prost::alloc::string::String), + #[prost(message, tag="164")] + MouseScrollUpInPaneIdPayload(super::MouseScrollInPaneIdPayload), + #[prost(message, tag="165")] + MouseScrollDownInPaneIdPayload(super::MouseScrollInPaneIdPayload), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -637,6 +641,16 @@ pub struct ScrollDownInPaneIdPayload { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct MouseScrollInPaneIdPayload { + #[prost(message, optional, tag="1")] + pub pane_id: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub position: ::core::option::Option, + #[prost(uint64, tag="3")] + pub lines: u64, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct ScrollToTopInPaneIdPayload { #[prost(message, optional, tag="1")] pub pane_id: ::core::option::Option, @@ -2152,6 +2166,8 @@ pub enum CommandName { KillSessionsAndReply = 212, DeleteDeadSessionAndReply = 213, DeleteAllDeadSessionsAndReply = 214, + MouseScrollUpInPaneId = 215, + MouseScrollDownInPaneId = 216, } impl CommandName { /// String value of the enum field names used in the ProtoBuf definition. @@ -2354,6 +2370,8 @@ impl CommandName { CommandName::KillSessionsAndReply => "KillSessionsAndReply", CommandName::DeleteDeadSessionAndReply => "DeleteDeadSessionAndReply", CommandName::DeleteAllDeadSessionsAndReply => "DeleteAllDeadSessionsAndReply", + CommandName::MouseScrollUpInPaneId => "MouseScrollUpInPaneId", + CommandName::MouseScrollDownInPaneId => "MouseScrollDownInPaneId", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -2553,6 +2571,8 @@ impl CommandName { "KillSessionsAndReply" => Some(Self::KillSessionsAndReply), "DeleteDeadSessionAndReply" => Some(Self::DeleteDeadSessionAndReply), "DeleteAllDeadSessionsAndReply" => Some(Self::DeleteAllDeadSessionsAndReply), + "MouseScrollUpInPaneId" => Some(Self::MouseScrollUpInPaneId), + "MouseScrollDownInPaneId" => Some(Self::MouseScrollDownInPaneId), _ => None, } } diff --git a/zellij-utils/build.rs b/zellij-utils/build.rs index 06672836a..1b21f4b53 100644 --- a/zellij-utils/build.rs +++ b/zellij-utils/build.rs @@ -76,7 +76,10 @@ fn main() { println!("cargo:rustc-env=VC_FRAME_HUMAN_VERSION={human_version}"); println!( "cargo:rustc-env=VC_FRAME_SOURCE_MANIFEST_DIR={}", - baked_source_manifest_dir(&manifest_dir, std::env::var("VC_FRAME_SOURCE_MANIFEST_DIR").ok()) + baked_source_manifest_dir( + &manifest_dir, + std::env::var("VC_FRAME_SOURCE_MANIFEST_DIR").ok() + ) ); println!("cargo:rustc-env=VC_FRAME_SOURCE_ORIGIN_URL={source_origin_url}"); println!("cargo:rustc-env=VC_FRAME_SOURCE_PROJECT={SOURCE_PROJECT}"); diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 5412f68e4..733f2960e 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -3617,6 +3617,8 @@ pub enum PluginCommand { ClearScreenForPaneId(PaneId), ScrollUpInPaneId(PaneId), ScrollDownInPaneId(PaneId), + MouseScrollUpInPaneId(PaneId, Position, usize), + MouseScrollDownInPaneId(PaneId, Position, usize), ScrollToTopInPaneId(PaneId), ScrollToBottomInPaneId(PaneId), PageScrollUpInPaneId(PaneId), diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index ecd0f7c9a..a12bc0302 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -399,6 +399,8 @@ pub enum ScreenContext { ClearScreenForPaneId, ScrollUpInPaneId, ScrollDownInPaneId, + MouseScrollUpInPaneId, + MouseScrollDownInPaneId, ScrollToTopInPaneId, ScrollToBottomInPaneId, PageScrollUpInPaneId, diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto index 729159e3a..515208fa2 100644 --- a/zellij-utils/src/plugin_api/plugin_command.proto +++ b/zellij-utils/src/plugin_api/plugin_command.proto @@ -207,6 +207,8 @@ enum CommandName { KillSessionsAndReply = 212; DeleteDeadSessionAndReply = 213; DeleteAllDeadSessionsAndReply = 214; + MouseScrollUpInPaneId = 215; + MouseScrollDownInPaneId = 216; } message PluginCommand { @@ -361,6 +363,8 @@ message PluginCommand { GetSessionListPayload get_session_list_payload = 161; KillSessionsPayload kill_sessions_and_reply_payload = 162; string delete_dead_session_and_reply_payload = 163; + MouseScrollInPaneIdPayload mouse_scroll_up_in_pane_id_payload = 164; + MouseScrollInPaneIdPayload mouse_scroll_down_in_pane_id_payload = 165; } } @@ -566,6 +570,12 @@ message ScrollDownInPaneIdPayload { PaneId pane_id = 1; } +message MouseScrollInPaneIdPayload { + PaneId pane_id = 1; + action.Position position = 2; + uint64 lines = 3; +} + message ScrollToTopInPaneIdPayload { PaneId pane_id = 1; } diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs index 83dd9c49b..8b012c550 100644 --- a/zellij-utils/src/plugin_api/plugin_command.rs +++ b/zellij-utils/src/plugin_api/plugin_command.rs @@ -1,5 +1,8 @@ pub use super::generated_api::api::{ - action::{Action as ProtobufAction, PaneIdAndShouldFloat, SwitchToModePayload}, + action::{ + Action as ProtobufAction, PaneIdAndShouldFloat, Position as ProtobufPosition, + SwitchToModePayload, + }, event::{ EventNameList as ProtobufEventNameList, Header, ResurrectableSession as ProtobufResurrectableSession, @@ -55,10 +58,11 @@ pub use super::generated_api::api::{ HighlightStyle as ProtobufHighlightStyle, HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload, KillSessionsResponse as ProtobufKillSessionsResponse, ListTokensResponse, - LoadNewPluginPayload, MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, - MovePaneWithPaneIdPayload, MovePayload, NewPluginArgs as ProtobufNewPluginArgs, - NewTabPayload, NewTabResponse as ProtobufNewTabResponse, - NewTabsResponse as ProtobufNewTabsResponse, NewTabsWithLayoutInfoPayload, + LoadNewPluginPayload, MessageToPluginPayload, MouseScrollInPaneIdPayload, + MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload, + NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload, + NewTabResponse as ProtobufNewTabResponse, NewTabsResponse as ProtobufNewTabsResponse, + NewTabsWithLayoutInfoPayload, OpenCommandPaneBackgroundResponse as ProtobufOpenCommandPaneBackgroundResponse, OpenCommandPaneFloatingNearPluginPayload, OpenCommandPaneFloatingNearPluginResponse as ProtobufOpenCommandPaneFloatingNearPluginResponse, @@ -146,6 +150,30 @@ use std::collections::BTreeMap; use std::convert::TryFrom; use std::path::PathBuf; +pub const MAX_MOUSE_SCROLL_LINES_IN_PANE_ID: usize = 100; + +fn mouse_scroll_position_from_protobuf( + protobuf_position: ProtobufPosition, +) -> Result { + let line = i32::try_from(protobuf_position.line) + .map_err(|_| "Mouse scroll pane position line does not fit i32")?; + if line < 0 { + return Err("Mouse scroll pane position line cannot be negative"); + } + let column = u16::try_from(protobuf_position.column) + .map_err(|_| "Mouse scroll pane position column does not fit u16")?; + Ok(crate::position::Position::new(line, column)) +} + +fn mouse_scroll_lines_from_protobuf(lines: u64) -> Result { + let lines = + usize::try_from(lines).map_err(|_| "Mouse scroll pane line count does not fit usize")?; + if lines > MAX_MOUSE_SCROLL_LINES_IN_PANE_ID { + return Err("Mouse scroll pane line count exceeds maximum"); + } + Ok(lines) +} + impl From for FloatingPaneCoordinates { fn from(val: ProtobufFloatingPaneCoordinates) -> Self { FloatingPaneCoordinates { @@ -1447,6 +1475,42 @@ impl TryFrom for PluginCommand { Some(CommandName::DeleteAllDeadSessionsAndReply) => { Ok(PluginCommand::DeleteAllDeadSessionsAndReply) }, + Some(CommandName::MouseScrollUpInPaneId) => match protobuf_plugin_command.payload { + Some(Payload::MouseScrollUpInPaneIdPayload(payload)) => { + let pane_id = payload + .pane_id + .ok_or("MouseScrollUpInPaneId requires a pane id")? + .try_into()?; + let position = mouse_scroll_position_from_protobuf( + payload + .position + .ok_or("MouseScrollUpInPaneId requires a position")?, + )?; + let lines = mouse_scroll_lines_from_protobuf(payload.lines)?; + Ok(PluginCommand::MouseScrollUpInPaneId( + pane_id, position, lines, + )) + }, + _ => Err("Mismatched payload for MouseScrollUpInPaneId"), + }, + Some(CommandName::MouseScrollDownInPaneId) => match protobuf_plugin_command.payload { + Some(Payload::MouseScrollDownInPaneIdPayload(payload)) => { + let pane_id = payload + .pane_id + .ok_or("MouseScrollDownInPaneId requires a pane id")? + .try_into()?; + let position = mouse_scroll_position_from_protobuf( + payload + .position + .ok_or("MouseScrollDownInPaneId requires a position")?, + )?; + let lines = mouse_scroll_lines_from_protobuf(payload.lines)?; + Ok(PluginCommand::MouseScrollDownInPaneId( + pane_id, position, lines, + )) + }, + _ => Err("Mismatched payload for MouseScrollDownInPaneId"), + }, Some(CommandName::DumpSessionLayout) => match protobuf_plugin_command.payload { Some(Payload::DumpSessionLayoutPayload(payload)) => { Ok(PluginCommand::DumpSessionLayout { @@ -3315,6 +3379,34 @@ impl TryFrom for ProtobufPluginCommand { name: CommandName::DeleteAllDeadSessionsAndReply as i32, payload: None, }), + PluginCommand::MouseScrollUpInPaneId(pane_id, position, lines) => { + Ok(ProtobufPluginCommand { + name: CommandName::MouseScrollUpInPaneId as i32, + payload: Some(Payload::MouseScrollUpInPaneIdPayload( + MouseScrollInPaneIdPayload { + pane_id: Some(pane_id.try_into()?), + position: Some(ProtobufPosition::try_from(position)?), + lines: lines + .try_into() + .map_err(|_| "MouseScrollUpInPaneId line count does not fit u64")?, + }, + )), + }) + }, + PluginCommand::MouseScrollDownInPaneId(pane_id, position, lines) => { + Ok(ProtobufPluginCommand { + name: CommandName::MouseScrollDownInPaneId as i32, + payload: Some(Payload::MouseScrollDownInPaneIdPayload( + MouseScrollInPaneIdPayload { + pane_id: Some(pane_id.try_into()?), + position: Some(ProtobufPosition::try_from(position)?), + lines: lines.try_into().map_err( + |_| "MouseScrollDownInPaneId line count does not fit u64", + )?, + }, + )), + }) + }, PluginCommand::DumpSessionLayout { tab_index } => Ok(ProtobufPluginCommand { name: CommandName::DumpSessionLayout as i32, payload: tab_index.map(|idx| { @@ -5053,3 +5145,128 @@ impl From for ProtobufOpenPluginPaneFloatingResp } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::position::Position; + + fn protobuf_mouse_scroll_command( + scroll_up: bool, + line: i64, + column: i64, + lines: u64, + ) -> ProtobufPluginCommand { + let payload = MouseScrollInPaneIdPayload { + pane_id: Some(PaneId::Terminal(7).try_into().unwrap()), + position: Some(ProtobufPosition { line, column }), + lines, + }; + if scroll_up { + ProtobufPluginCommand { + name: CommandName::MouseScrollUpInPaneId as i32, + payload: Some(Payload::MouseScrollUpInPaneIdPayload(payload)), + } + } else { + ProtobufPluginCommand { + name: CommandName::MouseScrollDownInPaneId as i32, + payload: Some(Payload::MouseScrollDownInPaneIdPayload(payload)), + } + } + } + + #[test] + fn mouse_scroll_in_pane_id_roundtrips() { + for command in [ + PluginCommand::MouseScrollUpInPaneId(PaneId::Terminal(7), Position::new(3, 11), 2), + PluginCommand::MouseScrollDownInPaneId(PaneId::Terminal(8), Position::new(4, 12), 5), + ] { + let protobuf: ProtobufPluginCommand = command.try_into().unwrap(); + let decoded = PluginCommand::try_from(protobuf).unwrap(); + match decoded { + PluginCommand::MouseScrollUpInPaneId(pane_id, position, lines) => { + assert_eq!(pane_id, PaneId::Terminal(7)); + assert_eq!(position, Position::new(3, 11)); + assert_eq!(lines, 2); + }, + PluginCommand::MouseScrollDownInPaneId(pane_id, position, lines) => { + assert_eq!(pane_id, PaneId::Terminal(8)); + assert_eq!(position, Position::new(4, 12)); + assert_eq!(lines, 5); + }, + other => panic!("unexpected roundtrip command: {other:?}"), + } + } + } + + #[test] + fn mouse_scroll_in_pane_id_rejects_invalid_positions() { + let invalid_positions = [ + (-1, 0, "Mouse scroll pane position line cannot be negative"), + ( + i64::from(i32::MAX) + 1, + 0, + "Mouse scroll pane position line does not fit i32", + ), + (0, -1, "Mouse scroll pane position column does not fit u16"), + ( + 0, + i64::from(u16::MAX) + 1, + "Mouse scroll pane position column does not fit u16", + ), + ]; + + for scroll_up in [true, false] { + for (line, column, expected_error) in invalid_positions { + let protobuf = protobuf_mouse_scroll_command(scroll_up, line, column, 1); + assert_eq!( + PluginCommand::try_from(protobuf).unwrap_err(), + expected_error + ); + } + } + } + + #[test] + fn mouse_scroll_in_pane_id_rejects_excessive_line_counts() { + for scroll_up in [true, false] { + let maximum = protobuf_mouse_scroll_command( + scroll_up, + 0, + 0, + MAX_MOUSE_SCROLL_LINES_IN_PANE_ID as u64, + ); + assert!(PluginCommand::try_from(maximum).is_ok()); + + let excessive = protobuf_mouse_scroll_command( + scroll_up, + 0, + 0, + MAX_MOUSE_SCROLL_LINES_IN_PANE_ID as u64 + 1, + ); + assert_eq!( + PluginCommand::try_from(excessive).unwrap_err(), + "Mouse scroll pane line count exceeds maximum" + ); + } + + for excessive_command in [ + PluginCommand::MouseScrollUpInPaneId( + PaneId::Terminal(7), + Position::new(0, 0), + MAX_MOUSE_SCROLL_LINES_IN_PANE_ID + 1, + ), + PluginCommand::MouseScrollDownInPaneId( + PaneId::Terminal(7), + Position::new(0, 0), + MAX_MOUSE_SCROLL_LINES_IN_PANE_ID + 1, + ), + ] { + let protobuf = ProtobufPluginCommand::try_from(excessive_command).unwrap(); + assert_eq!( + PluginCommand::try_from(protobuf).unwrap_err(), + "Mouse scroll pane line count exceeds maximum" + ); + } + } +}