aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/terminal.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input/terminal.rs')
-rw-r--r--src/app/input/terminal.rs2181
1 files changed, 0 insertions, 2181 deletions
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
deleted file mode 100644
index 0b910621..00000000
--- a/src/app/input/terminal.rs
+++ /dev/null
@@ -1,2181 +0,0 @@
-use bytes::Bytes;
-use crossterm::event::KeyCode;
-use tracing::{debug, warn};
-
-use crate::{
- app::{App, InputSourceId, Mode, TerminalInputTarget},
- input::TerminalKey,
-};
-
-struct PreparedPaneInput {
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- target: TerminalInputTarget,
- bytes: Bytes,
-}
-
-enum PreparedPopupInput {
- NotOpen,
- Consumed,
- Bytes {
- target: TerminalInputTarget,
- bytes: Bytes,
- },
-}
-
-fn is_modifier_only_key(code: &KeyCode) -> bool {
- matches!(code, KeyCode::Modifier(_))
-}
-
-impl App {
- #[cfg(test)]
- pub(crate) fn handle_terminal_key_headless(
- &mut self,
- key: TerminalKey,
- ) -> Option<TerminalInputTarget> {
- self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key)
- }
-
- pub(crate) fn handle_terminal_key_headless_from(
- &mut self,
- source_id: InputSourceId,
- key: TerminalKey,
- ) -> Option<TerminalInputTarget> {
- match self.prepare_popup_key_forward(key.clone()) {
- PreparedPopupInput::NotOpen => {}
- PreparedPopupInput::Consumed => return None,
- PreparedPopupInput::Bytes { target, bytes } => {
- let Some(runtime) = self.popup_runtime() else {
- self.close_popup_pane();
- return None;
- };
- return runtime.try_send_bytes(bytes).is_ok().then_some(target);
- }
- }
-
- let input = self.prepare_terminal_key_forward(source_id, key)?;
- let sent = self
- .lookup_runtime_sender(input.ws_idx, input.pane_id)
- .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
- sent.then_some(input.target)
- }
-
- fn prepare_terminal_key_forward(
- &mut self,
- source_id: InputSourceId,
- key: TerminalKey,
- ) -> Option<PreparedPaneInput> {
- let key_event = key.as_key_event();
- if self.try_copy_retained_selection(source_id, key.clone()) {
- return None;
- }
-
- self.state.clear_selection();
- self.selection_autoscroll_deadline = None;
- self.state.update_dismissed = true;
-
- if let Some(action) =
- super::terminal_direct_non_indexed_navigation_action(&self.state, &key)
- {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- action = ?action,
- "intercepted terminal direct keybinding before forwarding to pane"
- );
- if action == super::navigate::NavigateAction::EditScrollback {
- self.launch_focused_scrollback_editor();
- } else {
- self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
- }
- return None;
- }
-
- if let Some(binding) = super::navigate::command_for_key(
- &self.state,
- &key,
- super::navigate::BindingDispatch::Direct,
- ) {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- command = %binding.label,
- "intercepted terminal direct custom command before forwarding to pane"
- );
- self.launch_custom_command(binding, super::navigate::ActionContext::Direct);
- return None;
- }
-
- if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- action = ?action,
- "intercepted terminal direct indexed keybinding before forwarding to pane"
- );
- self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
- return None;
- }
-
- if self.state.is_prefix_key(&key) {
- self.state.mode = Mode::Prefix;
- return None;
- }
-
- if is_modifier_only_key(&key_event.code) {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- "dropping modifier-only terminal key event instead of forwarding it to pane"
- );
- return None;
- }
-
- let ws_idx = self.state.active?;
- let ws = self.state.workspaces.get(ws_idx)?;
- let pane_id = ws.focused_pane_id()?;
- let terminal_id = ws.terminal_id(pane_id)?.clone();
- let rt =
- self.state
- .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
-
- // Intercept plain PageUp/PageDown presses for pane scrollback only
- // when the focused pane looks like a shell transcript. Normal-screen
- // pagers such as `less -X` keep the primary screen but enter
- // application cursor mode while they own special keys.
- // Modified page keys are pane shortcuts, and release events should not
- // produce a second host-scroll action.
- // Only intercept when we know the pane state; if input_state is unknown,
- // fail-open and forward the key to the pane.
- if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown)
- && key_event.modifiers.is_empty()
- {
- if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() {
- if host_scroll {
- if key_event.kind == crossterm::event::KeyEventKind::Release {
- return None;
- }
- if matches!(
- key_event.kind,
- crossterm::event::KeyEventKind::Press
- | crossterm::event::KeyEventKind::Repeat
- ) {
- let lines = self
- .state
- .pane_info_by_id(pane_id)
- .map(|info| info.inner_rect.height as usize)
- .unwrap_or(10)
- .max(1);
- if key_event.code == KeyCode::PageUp {
- self.state
- .scroll_pane_up(&self.terminal_runtimes, pane_id, lines);
- } else {
- self.state
- .scroll_pane_down(&self.terminal_runtimes, pane_id, lines);
- }
- debug!(
- code = ?key_event.code,
- lines,
- "intercepted page key for pane scrollback"
- );
- return None;
- }
- }
- }
- }
-
- rt.scroll_reset();
- let protocol = rt.keyboard_protocol();
- let bytes = rt.encode_terminal_key(key.clone());
-
- if matches!(key_event.code, KeyCode::Esc)
- || key_event
- .modifiers
- .contains(crossterm::event::KeyModifiers::ALT)
- {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- protocol = ?protocol,
- encoded = ?bytes,
- "forwarding potentially-ambiguous terminal key to pane"
- );
- }
-
- if bytes.is_empty() {
- if key.kind != crossterm::event::KeyEventKind::Release
- && !matches!(
- key.code,
- KeyCode::CapsLock
- | KeyCode::ScrollLock
- | KeyCode::NumLock
- | KeyCode::PrintScreen
- | KeyCode::Pause
- | KeyCode::Menu
- | KeyCode::KeypadBegin
- | KeyCode::Media(_)
- | KeyCode::Modifier(_)
- )
- {
- warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
- }
- return None;
- }
-
- Some(PreparedPaneInput {
- ws_idx,
- pane_id,
- target: TerminalInputTarget { terminal_id },
- bytes: Bytes::from(bytes),
- })
- }
-
- fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput {
- if self.state.popup_pane.is_none() {
- return PreparedPopupInput::NotOpen;
- }
- let Some(terminal_id) = self
- .state
- .popup_pane
- .as_ref()
- .map(|popup| popup.terminal_id.clone())
- else {
- return PreparedPopupInput::NotOpen;
- };
- let Some(rt) = self.terminal_runtimes.get(&terminal_id) else {
- self.close_popup_pane();
- return PreparedPopupInput::Consumed;
- };
- rt.scroll_reset();
- let bytes = rt.encode_terminal_key(key.clone());
- self.state.mode = Mode::Terminal;
- if bytes.is_empty() {
- PreparedPopupInput::Consumed
- } else {
- PreparedPopupInput::Bytes {
- target: TerminalInputTarget { terminal_id },
- bytes: Bytes::from(bytes),
- }
- }
- }
-
- pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
- if self.state.popup_pane.is_none()
- && matches!(self.state.mode, Mode::Prefix | Mode::Navigate)
- {
- return true;
- }
-
- let runtime = if self.state.popup_pane.is_some() {
- self.popup_runtime()
- } else if self.state.mode == Mode::Terminal {
- self.state.active.and_then(|ws_idx| {
- self.state
- .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
- })
- } else {
- None
- };
-
- runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested)
- }
-
- fn terminal_input_runtime(
- &self,
- target: &TerminalInputTarget,
- ) -> Option<&crate::terminal::TerminalRuntime> {
- if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) {
- return Some(runtime);
- }
- #[cfg(test)]
- for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
- for tab in &workspace.tabs {
- for (&pane_id, pane) in &tab.panes {
- if pane.attached_terminal_id == target.terminal_id {
- return self.state.runtime_for_pane_in_workspace(
- &self.terminal_runtimes,
- ws_idx,
- pane_id,
- );
- }
- }
- }
- }
- None
- }
-
- pub(crate) fn forward_terminal_key_to_target_headless(
- &self,
- target: &TerminalInputTarget,
- key: TerminalKey,
- ) -> bool {
- let Some(runtime) = self.terminal_input_runtime(target) else {
- return false;
- };
- let bytes = runtime.encode_terminal_key(key.clone());
- bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
- }
-
- pub(crate) async fn forward_terminal_key_to_target(
- &self,
- target: &TerminalInputTarget,
- key: TerminalKey,
- ) -> bool {
- let Some(runtime) = self.terminal_input_runtime(target) else {
- return false;
- };
- let bytes = runtime.encode_terminal_key(key.clone());
- bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
- }
-
- fn take_pressed_keys_for_source(
- &mut self,
- source_id: crate::app::InputSourceId,
- ) -> Vec<super::ForwardedInputLease> {
- self.input_leases.remove_source(source_id)
- }
-
- pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) {
- for pressed in self.input_leases.remove_target(target) {
- let release = pressed
- .key
- .with_kind(crossterm::event::KeyEventKind::Release);
- let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
- }
- }
-
- pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
- // Pending URL clicks survive this call; see clear_input_source.
- self.state.clear_chrome_gesture(source_id);
- for pressed in self.take_pressed_keys_for_source(source_id) {
- let release = pressed
- .key
- .with_kind(crossterm::event::KeyEventKind::Release);
- let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
- }
- }
-
- pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
- // Pending URL clicks survive this call; see clear_input_source.
- self.state.clear_chrome_gesture(source_id);
- for pressed in self.take_pressed_keys_for_source(source_id) {
- let release = pressed
- .key
- .with_kind(crossterm::event::KeyEventKind::Release);
- let _ = self
- .forward_terminal_key_to_target(&pressed.target, release)
- .await;
- }
- }
-
- pub(super) async fn handle_terminal_key(
- &mut self,
- key: TerminalKey,
- ) -> Option<TerminalInputTarget> {
- match self.prepare_popup_key_forward(key.clone()) {
- PreparedPopupInput::NotOpen => {}
- PreparedPopupInput::Consumed => return None,
- PreparedPopupInput::Bytes { target, bytes } => {
- let Some(runtime) = self.popup_runtime() else {
- self.close_popup_pane();
- return None;
- };
- return runtime.send_bytes(bytes).await.is_ok().then_some(target);
- }
- }
-
- let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?;
- let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
- runtime.send_bytes(input.bytes).await.is_ok()
- } else {
- false
- };
- sent.then_some(input.target)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
- use ratatui::layout::Rect;
-
- #[cfg(target_os = "linux")]
- use super::super::wait_for_detached_process_reap;
- use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
- #[cfg(unix)]
- use super::super::{unique_temp_path, wait_for_file};
- use super::*;
- use crate::{config::Config, events::AppEvent, workspace::Workspace};
-
- #[cfg(unix)]
- fn app_with_spawned_workspace() -> App {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.default_shell = "/bin/sh".into();
- let (workspace, terminal, runtime) = Workspace::new(
- std::env::current_dir().unwrap_or_else(|_| "/".into()),
- 24,
- 80,
- app.state.pane_scrollback_limit_bytes,
- app.state.host_terminal_theme,
- app.state.host_terminal_appearance,
- crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
- app.event_tx.clone(),
- app.render_notify.clone(),
- app.render_dirty.clone(),
- )
- .expect("workspace should spawn");
- app.state.workspaces = vec![workspace];
- app.terminal_runtimes.insert(terminal.id.clone(), runtime);
- app.state.terminals.insert(terminal.id.clone(), terminal);
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app
- }
-
- #[cfg(unix)]
- fn shutdown_test_runtimes(app: &mut App) {
- for (_, runtime) in app.terminal_runtimes.drain() {
- runtime.shutdown();
- }
- }
-
- fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.insert_test_runtime(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- bytes,
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- (app, info)
- }
-
- fn double_click(app: &mut App, col: u16, row: u16) {
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
- }
-
- fn modified_mouse(
- kind: MouseEventKind,
- col: u16,
- row: u16,
- modifiers: KeyModifiers,
- ) -> crossterm::event::MouseEvent {
- crossterm::event::MouseEvent {
- kind,
- column: col,
- row,
- modifiers,
- }
- }
-
- fn clipboard_write_content(app: &mut App) -> Vec<u8> {
- match app.event_rx.try_recv().expect("clipboard write event") {
- AppEvent::ClipboardWrite { content } => content,
- event => panic!("unexpected event: {event:?}"),
- }
- }
-
- fn assert_visible_selection(app: &App) {
- assert!(app
- .state
- .selection
- .as_ref()
- .is_some_and(crate::selection::Selection::is_visible));
- }
-
- #[cfg(unix)]
- fn install_test_link_handler(app: &mut App) {
- let plugin_root = std::env::temp_dir();
- app.state.installed_plugins = std::collections::HashMap::from([(
- "example.links".to_string(),
- crate::api::schema::InstalledPluginInfo {
- plugin_id: "example.links".into(),
- name: "Links".into(),
- version: "0.1.0".into(),
- min_herdr_version: "0.6.10".into(),
- description: None,
- manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(),
- plugin_root: plugin_root.display().to_string(),
- enabled: true,
- platforms: None,
- build: Vec::new(),
- startup: Vec::new(),
- actions: vec![crate::api::schema::PluginManifestAction {
- id: "open".into(),
- title: "Open link".into(),
- description: None,
- contexts: Vec::new(),
- platforms: None,
- command: vec!["sh".into(), "-c".into(), ":".into()],
- }],
- events: Vec::new(),
- panes: Vec::new(),
- link_handlers: vec![crate::api::schema::PluginManifestLinkHandler {
- id: "github-issue".into(),
- title: "Open GitHub issue".into(),
- pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
- action: "open".into(),
- platforms: None,
- }],
- source: crate::api::schema::PluginSourceInfo::default(),
- warnings: Vec::new(),
- },
- )]);
- }
-
- #[tokio::test]
- async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.insert_test_runtime(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app
- .state
- .runtime_for_pane(&app.terminal_runtimes, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- let start_row = info.inner_rect.y;
- let start_col = info.inner_rect.x + 2;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- start_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- start_col,
- info.inner_rect.y.saturating_sub(1),
- ));
-
- let end_metrics = app
- .state
- .runtime_for_pane(&app.terminal_runtimes, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after drag");
- assert_eq!(
- end_metrics.offset_from_bottom,
- start_metrics.offset_from_bottom + 3
- );
-
- let selection = app.state.selection.as_ref().expect("selection after drag");
- assert!(selection.is_visible());
- assert_eq!(
- selection.ordered_cells(),
- (
- (
- (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
- 2,
- ),
- (start_metrics.max_offset_from_bottom as u32, 2),
- )
- );
- }
-
- #[tokio::test]
- async fn releasing_dragged_selection_clears_highlight_after_copy() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.insert_test_runtime(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let row = info.inner_rect.y;
- let start_col = info.inner_rect.x + 1;
- let end_col = info.inner_rect.x + 4;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
- assert!(app.state.selection.is_some());
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
-
- assert!(app.state.selection.is_none());
- }
-
- #[tokio::test]
- async fn drag_copy_then_click_does_not_reuse_double_click_candidate() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- let row = info.inner_rect.y;
- let start_col = info.inner_rect.x;
- let end_col = info.inner_rect.x + 4;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
- assert!(app.last_pane_click.is_some());
-
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
- assert!(app.last_pane_click.is_none());
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
- assert!(app.last_pane_click.is_none());
- assert_eq!(clipboard_write_content(&mut app), b"alpha");
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
-
- assert!(app.last_pane_click.is_some());
- assert!(app.event_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn double_click_selects_and_copies_word() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega");
- let col = info.inner_rect.x + 13;
- let row = info.inner_rect.y;
- double_click(&mut app, col, row);
-
- assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega");
- assert_visible_selection(&app);
- }
-
- #[tokio::test]
- async fn copy_on_select_disabled_keeps_drag_selection_without_copying() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- app.state.copy_on_select = false;
- let row = info.inner_rect.y;
- let start_col = info.inner_rect.x;
- let end_col = info.inner_rect.x + 4;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
- assert_visible_selection(&app);
- assert!(!app
- .state
- .selection
- .as_ref()
- .is_some_and(crate::selection::Selection::is_finalized));
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
-
- assert_visible_selection(&app);
- assert_eq!(
- app.state
- .selection
- .as_ref()
- .map(crate::selection::Selection::ordered_cells),
- Some(((0, 0), (0, 4)))
- );
- assert!(app
- .state
- .selection
- .as_ref()
- .is_some_and(crate::selection::Selection::is_finalized));
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- assert!(app.selection_highlight_clear_deadline.is_none());
- assert!(app.event_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- app.state.copy_on_select = false;
- let col = info.inner_rect.x + 2;
- let row = info.inner_rect.y;
-
- double_click(&mut app, col, row);
-
- assert_visible_selection(&app);
- assert!(app.selection_highlight_clear_deadline.is_none());
- assert!(app.event_rx.try_recv().is_err());
-
- app.handle_terminal_key_headless(TerminalKey::new(
- KeyCode::Char('c'),
- KeyModifiers::CONTROL,
- ));
-
- assert_eq!(clipboard_write_content(&mut app), b"alpha");
- assert!(app.state.selection.is_none());
- }
-
- #[tokio::test]
- async fn new_drag_cancels_stale_double_click_highlight_deadline() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- let row = info.inner_rect.y;
- let word_col = info.inner_rect.x + 2;
-
- double_click(&mut app, word_col, row);
- assert_eq!(clipboard_write_content(&mut app), b"alpha");
- let stale_deadline = app
- .selection_highlight_clear_deadline
- .expect("double-click highlight deadline");
- app.state.copy_on_select = false;
-
- let start_col = info.inner_rect.x + 6;
- let end_col = info.inner_rect.x + 9;
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
- assert!(app.selection_highlight_clear_deadline.is_none());
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
-
- assert_visible_selection(&app);
- assert!(!app
- .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1)));
- assert_visible_selection(&app);
- assert!(app.event_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn ignored_left_down_keeps_double_click_highlight_deadline() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- let col = info.inner_rect.x + 2;
- let row = info.inner_rect.y;
-
- double_click(&mut app, col, row);
- assert_eq!(clipboard_write_content(&mut app), b"alpha");
- let deadline = app
- .selection_highlight_clear_deadline
- .expect("double-click highlight deadline");
- app.state.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::Finished,
- title: "finished".into(),
- context: "missing".into(),
- position: None,
- target: Some(crate::app::state::ToastTarget {
- workspace_id: "missing".into(),
- pane_id: info.id,
- }),
- });
- app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1);
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));
-
- assert_visible_selection(&app);
- assert_eq!(app.selection_highlight_clear_deadline, Some(deadline));
- assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1)));
- assert!(app.state.selection.is_none());
- }
-
- #[tokio::test]
- async fn double_click_uses_display_columns_for_wide_chars() {
- let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes());
- let col = info.inner_rect.x + 8;
- let row = info.inner_rect.y;
- double_click(&mut app, col, row);
-
- assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes());
- assert_visible_selection(&app);
- }
-
- #[tokio::test]
- async fn double_click_copies_quoted_path_without_quotes() {
- let line = r#"cat "/tmp/build output/log.txt""#;
- let (mut app, info) = app_with_screen_bytes(line.as_bytes());
- let col = info.inner_rect.x + line.find("output").expect("path segment") as u16;
- let row = info.inner_rect.y;
- double_click(&mut app, col, row);
-
- assert_eq!(
- clipboard_write_content(&mut app),
- b"/tmp/build output/log.txt"
- );
- assert_visible_selection(&app);
- }
-
- #[tokio::test]
- async fn double_click_excludes_trailing_punctuation() {
- let (mut app, info) = app_with_screen_bytes(b"done.");
- let col = info.inner_rect.x + 2;
- let row = info.inner_rect.y;
- double_click(&mut app, col, row);
-
- assert_eq!(clipboard_write_content(&mut app), b"done");
- assert_visible_selection(&app);
- }
-
- #[tokio::test]
- async fn modified_pane_click_does_not_seed_double_click_copy() {
- let (mut app, info) = app_with_screen_bytes(b"alpha beta");
- let col = info.inner_rect.x + 7;
- let row = info.inner_rect.y;
-
- app.handle_mouse(modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- col,
- row,
- KeyModifiers::CONTROL,
- ));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
-
- assert!(app.event_rx.try_recv().is_err());
- assert!(app.selection_highlight_clear_deadline.is_none());
- }
-
- #[cfg(target_os = "linux")]
- #[tokio::test]
- async fn ctrl_click_url_reaps_failed_opener() {
- let opener_dir = unique_temp_path("url-opener");
- let record_path = opener_dir.join("record");
- let opener_path = opener_dir.join("xdg-open");
- std::fs::create_dir_all(&opener_dir).expect("fake opener directory");
- std::fs::write(
- &opener_path,
- "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$1\" > \"$2\"\nexit 3\n",
- )
- .expect("fake opener script");
-
- let url = "https://example.com/akbash-2903";
- let line = format!("see {url}");
- let (mut app, info) = app_with_screen_bytes(line.as_bytes());
- let col = info.inner_rect.x + line.find("example").expect("url host") as u16;
- let handled = app.handle_modified_url_click_with(
- 41,
- modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- col,
- info.inner_rect.y,
- KeyModifiers::CONTROL,
- ),
- |clicked_url| {
- std::process::Command::new("/bin/sh")
- .arg(&opener_path)
- .arg(clicked_url)
- .arg(&record_path)
- .stdin(std::process::Stdio::null())
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn()
- .map(Some)
- },
- );
- assert!(handled);
-
- let record = wait_for_file(&record_path);
- let mut lines = record.lines();
- let pid = lines
- .next()
- .expect("opener pid")
- .parse::<u32>()
- .expect("numeric opener pid");
- assert_eq!(lines.next(), Some(url));
-
- let reaped = wait_for_detached_process_reap(&mut app, pid).await;
- if !reaped {
- unsafe {
- libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0);
- }
- }
-
- let _ = std::fs::remove_dir_all(&opener_dir);
- assert!(reaped, "failed URL opener child {pid} was not reaped");
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() {
- let line = "see https://github.com/herdrdev/herdr/issues/1761";
- let col = line.find("github").expect("url host") as u16;
- let (mut app, info) = app_with_screen_bytes(b"");
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 0,
- screen.as_bytes(),
- 4,
- );
- app.state.insert_test_runtime(pane_id, runtime);
- install_test_link_handler(&mut app);
- let mut send_mouse = |source_id, kind, column, modifiers| {
- app.handle_mouse_from_input_source(
- source_id,
- modified_mouse(kind, column, info.inner_rect.y, modifiers),
- );
- };
- let down = MouseEventKind::Down(MouseButton::Left);
- let up = MouseEventKind::Up(MouseButton::Left);
- let url_x = info.inner_rect.x + col;
-
- send_mouse(41, down, url_x, KeyModifiers::CONTROL);
- send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty());
- send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty());
- send_mouse(41, up, url_x, KeyModifiers::empty());
-
- assert_eq!(app.state.plugin_command_logs.len(), 1);
- assert_eq!(
- input_rx.try_recv().expect("other source mouse down"),
- Bytes::from_static(b"\x1b[<0;1;1M")
- );
- assert_eq!(
- input_rx.try_recv().expect("other source mouse up"),
- Bytes::from_static(b"\x1b[<0;1;1m")
- );
- assert!(
- input_rx.try_recv().is_err(),
- "handled URL click must not leave an unmatched release for the pane"
- );
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() {
- let line = "see https://github.com/herdrdev/herdr/issues/1761";
- let col = line.find("github").expect("url host") as u16;
- let (mut app, info) = app_with_screen_bytes(b"");
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 0,
- screen.as_bytes(),
- 4,
- );
- app.state.insert_test_runtime(pane_id, runtime);
- install_test_link_handler(&mut app);
- let url_x = info.inner_rect.x + col;
-
- app.handle_mouse_from_input_source(
- 41,
- modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- url_x,
- info.inner_rect.y,
- KeyModifiers::CONTROL,
- ),
- );
- assert_eq!(app.state.plugin_command_logs.len(), 1);
-
- // Opening the URL raises the browser, so the host terminal loses focus
- // while the button is still down.
- app.route_client_events_from(
- 41,
- vec![crate::raw_input::RawInputEvent::OuterFocusLost],
- false,
- );
-
- app.handle_mouse_from_input_source(
- 41,
- modified_mouse(
- MouseEventKind::Up(MouseButton::Left),
- url_x,
- info.inner_rect.y,
- KeyModifiers::empty(),
- ),
- );
-
- assert!(
- input_rx.try_recv().is_err(),
- "focus loss must not clear a pending URL click, so its release must stay out of the pane"
- );
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn ctrl_click_osc8_file_url_invokes_plugin_link_handler() {
- let uri = "file:///tmp/herdr-file-repro.txt";
- let screen = format!("\x1b]8;;{uri}\x1b\\FILE\x1b]8;;\x1b\\");
- let (mut app, info) = app_with_screen_bytes(screen.as_bytes());
- install_test_link_handler(&mut app);
- app.state
- .installed_plugins
- .get_mut("example.links")
- .expect("test plugin")
- .link_handlers[0]
- .pattern = r"^file:///tmp/herdr-file-repro\.txt$".into();
-
- let handled = app.handle_modified_url_click_with(
- 41,
- modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- info.inner_rect.x + 1,
- info.inner_rect.y,
- KeyModifiers::CONTROL,
- ),
- |_| panic!("matched file link should not use the system URL opener"),
- );
-
- assert!(handled);
- let log = app
- .state
- .plugin_command_logs
- .last()
- .expect("ctrl-click should start plugin link handler");
- assert_eq!(log.plugin_id, "example.links");
- assert_eq!(log.action_id.as_deref(), Some("open"));
-
- let (mut unmatched_app, unmatched_info) = app_with_screen_bytes(screen.as_bytes());
- install_test_link_handler(&mut unmatched_app);
- let unmatched_handled = unmatched_app.handle_modified_url_click_with(
- 42,
- modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- unmatched_info.inner_rect.x + 1,
- unmatched_info.inner_rect.y,
- KeyModifiers::CONTROL,
- ),
- |_| panic!("unmatched file link should not use the system URL opener"),
- );
-
- assert!(!unmatched_handled);
- assert!(unmatched_app.state.plugin_command_logs.is_empty());
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {
- let line = "see https://github.com/herdrdev/herdr/issues/398";
- let col = line.find("github").expect("url host") as u16;
-
- let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes());
- install_test_link_handler(&mut ctrl_app);
- ctrl_app.handle_mouse(modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- ctrl_info.inner_rect.x + col,
- ctrl_info.inner_rect.y,
- KeyModifiers::CONTROL,
- ));
-
- let ctrl_log = ctrl_app
- .state
- .plugin_command_logs
- .last()
- .expect("ctrl-click should start plugin link handler");
- assert_eq!(ctrl_log.plugin_id, "example.links");
- assert_eq!(ctrl_log.action_id.as_deref(), Some("open"));
-
- let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes());
- install_test_link_handler(&mut super_app);
- super_app.handle_mouse(modified_mouse(
- MouseEventKind::Down(MouseButton::Left),
- super_info.inner_rect.x + col,
- super_info.inner_rect.y,
- KeyModifiers::SUPER,
- ));
-
- assert!(super_app.state.plugin_command_logs.is_empty());
- }
-
- #[tokio::test]
- async fn pane_cell_url_resolver_finds_visible_url() {
- let line = "see https://example.com/pr/307.";
- let (app, info) = app_with_screen_bytes(line.as_bytes());
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let col = line.find("example").expect("url host") as u16;
-
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col)
- .as_deref(),
- Some("https://example.com/pr/307")
- );
- assert_eq!(
- app.state.url_at_pane_cell(
- &app.terminal_runtimes,
- pane_id,
- 0,
- info.inner_rect.width - 1
- ),
- None
- );
- }
-
- #[tokio::test]
- async fn pane_cell_url_resolver_finds_soft_wrapped_url() {
- let (_app, info) = app_with_screen_bytes(b"");
- let prefix = "https://example.com/";
- let padding = "a".repeat(info.inner_rect.width as usize - prefix.len());
- let url = format!("{prefix}{padding}tail");
- let (app, _info) = app_with_screen_bytes(url.as_bytes());
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
-
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
- .as_deref(),
- Some(url.as_str())
- );
- }
-
- #[tokio::test]
- async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() {
- let url = "https://example.com/mark";
- let screen = format!("e\u{301} {url}");
- let (app, _info) = app_with_screen_bytes(screen.as_bytes());
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
-
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2)
- .as_deref(),
- Some(url)
- );
- }
-
- #[tokio::test]
- async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() {
- let (_app, info) = app_with_screen_bytes(b"");
- let full_row = "x".repeat(info.inner_rect.width as usize);
- let url = "https://example.com/next";
- let screen = format!("{full_row}\n{url}");
- let (app, _info) = app_with_screen_bytes(screen.as_bytes());
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
-
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
- .as_deref(),
- None
- );
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1)
- .as_deref(),
- Some(url)
- );
- }
-
- #[tokio::test]
- async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() {
- let (_app, info) = app_with_screen_bytes(b"");
- let prefix = "https://example.com/";
- let padding = "b".repeat(info.inner_rect.width as usize - prefix.len());
- let url = format!("{prefix}{padding}tail");
- let (app, _info) = app_with_screen_bytes(url.as_bytes());
-
- let links =
- crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
-
- assert!(links.is_empty());
- }
-
- #[tokio::test]
- async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() {
- let url = "https://example.com/mark";
- let screen = format!("e\u{301} {url}");
- let (app, _info) = app_with_screen_bytes(screen.as_bytes());
-
- let links =
- crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
-
- assert!(links.is_empty());
- }
-
- #[tokio::test]
- async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() {
- let (_app, info) = app_with_screen_bytes(b"");
- let full_row = "x".repeat(info.inner_rect.width as usize);
- let url = "https://example.com/next";
- let screen = format!("{full_row}\n{url}");
- let (app, _info) = app_with_screen_bytes(screen.as_bytes());
- let links =
- crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
-
- assert!(links.is_empty());
- }
-
- #[tokio::test]
- async fn render_stream_exports_osc8_hyperlink_metadata() {
- let uri = "https://example.com/target";
- let (mut app, _info) =
- app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes());
- let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry(
- &mut app.state,
- &app.terminal_runtimes,
- ratatui::layout::Rect::new(0, 0, 106, 20),
- false,
- crate::kitty_graphics::HostCellSize::default(),
- );
- let links =
- crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
- let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
- &buffer, cursor, &links,
- );
- let ((x, y), symbol, _) = links
- .iter()
- .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri)
- .expect("OSC 8 link cell");
- let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x);
-
- assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
- assert_eq!(symbol, "l");
- assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0));
- }
-
- #[tokio::test]
- async fn pane_cell_url_resolver_prefers_osc8_hyperlink() {
- let (app, _info) = app_with_screen_bytes(
- b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\",
- );
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
-
- assert_eq!(
- app.state
- .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1)
- .as_deref(),
- Some("https://example.com/hidden-target")
- );
- }
-
- #[tokio::test]
- async fn double_click_highlight_clears_after_short_delay() {
- let (mut app, info) = app_with_screen_bytes(b"copied");
- let col = info.inner_rect.x + 2;
- let row = info.inner_rect.y;
- double_click(&mut app, col, row);
- assert_eq!(clipboard_write_content(&mut app), b"copied");
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
-
- assert!(app.event_rx.try_recv().is_err());
- assert!(app.state.selection.is_some());
- let deadline = app
- .selection_highlight_clear_deadline
- .expect("highlight clear deadline");
- assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false));
- assert!(app.state.selection.is_none());
- }
-
- #[tokio::test]
- async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 0,
- b"\x1b[?1002h\x1b[?1006h",
- 4,
- );
- ws.insert_test_runtime(pane_id, runtime);
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- app.state.copy_on_select = false;
-
- let col = info.inner_rect.x + 2;
- let row = info.inner_rect.y + 3;
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- col + 1,
- row + 1,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- col + 1,
- row + 1,
- ));
-
- assert!(app.event_rx.try_recv().is_err());
- assert!(app.state.selection.is_none());
- assert!(app.selection_highlight_clear_deadline.is_none());
- assert_eq!(
- input_rx.try_recv().expect("forwarded left mouse down"),
- Bytes::from_static(b"\x1b[<0;3;4M")
- );
- assert_eq!(
- input_rx.try_recv().expect("forwarded left mouse drag"),
- Bytes::from_static(b"\x1b[<32;4;5M")
- );
- assert_eq!(
- input_rx.try_recv().expect("forwarded left mouse up"),
- Bytes::from_static(b"\x1b[<0;4;5m")
- );
- assert!(input_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.insert_test_runtime(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app
- .state
- .runtime_for_pane(&app.terminal_runtimes, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- let top_row = info.inner_rect.y;
- let col = info.inner_rect.x + 2;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
- app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));
-
- let end_metrics = app
- .state
- .runtime_for_pane(&app.terminal_runtimes, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after wheel");
- assert_eq!(
- end_metrics.offset_from_bottom,
- start_metrics.offset_from_bottom + 3
- );
-
- let selection = app.state.selection.as_ref().expect("selection after wheel");
- assert!(selection.is_visible());
- assert_eq!(
- selection.ordered_cells(),
- (
- (
- (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
- 2,
- ),
- (start_metrics.max_offset_from_bottom as u32, 2),
- )
- );
- }
-
- #[tokio::test]
- async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
- app.state.view.pane_infos = app.state.workspaces[0]
- .active_tab()
- .unwrap()
- .layout
- .panes(Rect::new(0, 0, 80, 24));
- let focused_before = app.state.workspaces[0].layout.focused();
- app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h");
-
- app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
- .await;
-
- assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn terminal_direct_edit_scrollback_opens_editor_pane() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- let mut workspace = Workspace::test_new("test");
- let root_pane = workspace.tabs[0].root_pane;
- workspace.tabs[0].runtimes.insert(
- root_pane,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- 20,
- 5,
- 4096,
- b"alpha\nbeta\n",
- ),
- );
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let output_path = unique_temp_path("direct-edit-scrollback");
- let previous_editor = std::env::var_os("EDITOR");
- std::env::set_var(
- "EDITOR",
- format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
- );
- app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e");
-
- app.handle_terminal_key(TerminalKey::new(
- KeyCode::Char('e'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ))
- .await;
-
- match previous_editor {
- Some(value) => std::env::set_var("EDITOR", value),
- None => std::env::remove_var("EDITOR"),
- }
-
- let content = wait_for_file(&output_path);
- assert!(content.contains("alpha"));
- assert!(content.contains("beta"));
- assert_eq!(app.state.mode, Mode::Terminal);
-
- let _ = std::fs::remove_file(output_path);
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn direct_custom_command_runs_before_forwarding_to_pane() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let output_path = unique_temp_path("direct-custom-command");
- let command = format!("printf direct > '{}'", output_path.display());
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
- label: "ctrl+alt+g".into(),
- command,
- action: crate::config::CustomCommandAction::Shell,
- description: None,
- width: None,
- height: None,
- }];
-
- app.handle_terminal_key(TerminalKey::new(
- KeyCode::Char('g'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ))
- .await;
-
- assert_eq!(wait_for_file(&output_path), "direct");
- assert_eq!(app.state.mode, Mode::Terminal);
- let _ = std::fs::remove_file(output_path);
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn direct_custom_pane_command_opens_overlay_pane() {
- let mut app = app_with_spawned_workspace();
-
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
- label: "ctrl+alt+g".into(),
- command: "printf direct-pane".into(),
- action: crate::config::CustomCommandAction::Pane,
- description: None,
- width: None,
- height: None,
- }];
-
- app.handle_terminal_key(TerminalKey::new(
- KeyCode::Char('g'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ))
- .await;
-
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
- assert!(app.state.workspaces[0].tabs[0].zoomed);
- assert_eq!(app.state.mode, Mode::Terminal);
-
- shutdown_test_runtimes(&mut app);
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn direct_custom_popup_command_opens_layout_neutral_popup() {
- let mut app = app_with_spawned_workspace();
-
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
- label: "ctrl+alt+g".into(),
- command: "sleep 1".into(),
- action: crate::config::CustomCommandAction::Popup,
- description: None,
- width: Some(crate::popup_size::PopupSize::Cells(60)),
- height: Some(crate::popup_size::PopupSize::Cells(12)),
- }];
-
- app.handle_terminal_key(TerminalKey::new(
- KeyCode::Char('g'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ))
- .await;
-
- assert!(app.state.popup_pane.is_some());
- assert!(!app
- .popup_runtime()
- .unwrap()
- .agent_detection_enabled_for_test());
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
- assert!(!app.state.workspaces[0].tabs[0].zoomed);
- assert_eq!(app.state.mode, Mode::Terminal);
- let snapshot = crate::persist::capture(
- &app.state.workspaces,
- &app.state.terminals,
- &app.terminal_runtimes,
- app.state.active,
- app.state.selected,
- app.state.sidebar_width,
- app.state.sidebar_section_split,
- app.state.collapsed_space_keys.clone(),
- );
- assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1);
- assert!(matches!(
- snapshot.workspaces[0].tabs[0].layout,
- crate::persist::LayoutSnapshot::Pane(_)
- ));
-
- shutdown_test_runtimes(&mut app);
- }
-
- #[cfg(unix)]
- #[tokio::test]
- async fn direct_custom_popup_command_closes_after_exit() {
- let mut app = app_with_spawned_workspace();
- let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap();
- let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap();
-
- let output_path = unique_temp_path("custom-popup-command");
- let command = format!(
- "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
- output_path.display()
- );
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
- label: "ctrl+alt+g".into(),
- command,
- action: crate::config::CustomCommandAction::Popup,
- description: None,
- width: None,
- height: None,
- }];
-
- app.handle_terminal_key(TerminalKey::new(
- KeyCode::Char('g'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ))
- .await;
-
- assert_eq!(
- wait_for_file(&output_path),
- format!("unset|{focused_pane_id}")
- );
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
- while std::time::Instant::now() < deadline {
- app.drain_internal_events();
- if app.state.popup_pane.is_none() {
- break;
- }
- }
-
- assert!(app.state.popup_pane.is_none());
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
-
- shutdown_test_runtimes(&mut app);
- let _ = std::fs::remove_file(output_path);
- }
-
- #[tokio::test]
- async fn popup_forwards_escape_instead_of_closing() {
- let mut app = app_for_mouse_test();
- let (runtime, mut rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 40,
- 2,
- 1024,
- b"one\r\ntwo\r\nthree\r\n",
- 4,
- );
- runtime.scroll_up(1);
- assert!(runtime
- .scroll_metrics()
- .is_some_and(|metrics| metrics.offset_from_bottom > 0));
- app.install_test_popup_runtime(runtime);
- app.state.mode = Mode::Settings;
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()));
-
- assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b");
- assert!(app.state.popup_pane.is_some());
- assert_eq!(
- app.popup_runtime()
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .map(|metrics| metrics.offset_from_bottom),
- Some(0)
- );
- }
-
- #[tokio::test]
- async fn local_popup_input_waits_for_channel_capacity() {
- let mut app = app_for_mouse_test();
- let (runtime, mut rx) =
- crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1);
- runtime
- .try_send_bytes(Bytes::from_static(b"queued"))
- .unwrap();
- app.install_test_popup_runtime(runtime);
- app.state.mode = Mode::Settings;
-
- let mut send = Box::pin(
- app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())),
- );
- assert!(
- tokio::time::timeout(std::time::Duration::from_millis(20), &mut send)
- .await
- .is_err()
- );
-
- assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued");
- send.await;
- assert_eq!(rx.recv().await.unwrap().as_ref(), b"x");
- }
-
- #[tokio::test]
- async fn alt_backspace_is_forwarded_to_focused_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24));
- let info = pane_infos[0].clone();
- let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(
- info.inner_rect.width,
- info.inner_rect.height,
- );
- ws.tabs[0].runtimes.insert(pane_id, runtime);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
- app.handle_terminal_key_headless(key);
-
- let bytes = rx.try_recv().unwrap();
- assert_eq!(bytes.as_ref(), b"\x1b\x7f");
- assert!(rx.try_recv().is_err());
- }
-
- fn app_with_plain_scrollback(
- line_count: usize,
- ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) {
- let mut app = app_for_mouse_test();
- let mut workspace = Workspace::test_new("test");
- let pane_id = workspace.tabs[0].root_pane;
- let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let pane_info = pane_infos[0].clone();
- workspace.tabs[0].runtimes.insert(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- pane_info.inner_rect.width,
- pane_info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(line_count),
- ),
- );
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- (app, pane_id, pane_info)
- }
-
- fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize {
- app.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("pane scroll metrics")
- .offset_from_bottom
- }
-
- fn physical_page_up(repeat_count: u16) -> TerminalKey {
- TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record(
- crate::input::WindowsKeyRecord {
- key_down: true,
- repeat_count,
- virtual_key_code: 0x21,
- virtual_scan_code: 0x49,
- unicode: 0,
- control_key_state: 0x0100,
- },
- )
- }
-
- #[tokio::test]
- async fn page_up_scrolls_plain_shell_pane() {
- let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
- assert_eq!(pane_scroll_offset(&app, pane_id), 0);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
-
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- pane_info.inner_rect.height as usize
- );
- }
-
- #[tokio::test]
- async fn consumed_page_up_preserves_separate_and_grouped_repeats() {
- let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
- let page_up = physical_page_up(1);
- app.route_client_events(
- vec![
- crate::raw_input::RawInputEvent::Key(page_up.clone()),
- crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Repeat),
- ),
- crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Release),
- ),
- ],
- false,
- );
-
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- pane_info.inner_rect.height as usize * 2
- );
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))],
- false,
- );
-
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- pane_info.inner_rect.height as usize * 5
- );
- }
-
- #[tokio::test]
- async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() {
- let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
- let page_up = physical_page_up(1);
- for key in [
- page_up.clone(),
- page_up.clone().with_kind(KeyEventKind::Repeat),
- page_up.clone().with_kind(KeyEventKind::Release),
- ] {
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key))
- .await;
- }
-
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- pane_info.inner_rect.height as usize * 2
- );
-
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3)))
- .await;
-
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- pane_info.inner_rect.height as usize * 5
- );
- }
-
- #[tokio::test]
- async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let first_info = pane_infos
- .iter()
- .find(|info| info.id == first_pane)
- .expect("first pane info");
- ws.tabs[0].runtimes.insert(
- first_pane,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- first_info.inner_rect.width,
- first_info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(128),
- ),
- );
- let (second_runtime, mut second_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 80,
- 24,
- 0,
- b"\x1b[?1h\x1b[>15u",
- 3,
- );
- ws.tabs[0].runtimes.insert(second_pane, second_runtime);
- ws.tabs[0].layout.focus_pane(first_pane);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let page_up = physical_page_up(1);
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
- false,
- );
- assert!(app.state.focus_pane_in_workspace(0, second_pane));
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Repeat),
- )],
- false,
- );
- assert!(app.state.focus_pane_in_workspace(0, first_pane));
- app.route_client_events(
- vec![
- crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Repeat),
- ),
- crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
- ],
- false,
- );
-
- let first_repeat = second_rx.try_recv().expect("first forwarded repeat");
- let second_repeat = second_rx.try_recv().expect("owned repeat");
- let release = second_rx.try_recv().expect("owned release");
- assert_eq!(first_repeat, second_repeat);
- assert_ne!(second_repeat, release);
- assert!(second_rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
- ws.tabs[0].runtimes.insert(pane_id, runtime);
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let (popup_runtime, _popup_rx) =
- crate::terminal::TerminalRuntime::test_with_channel(40, 12);
- app.install_test_popup_runtime(popup_runtime);
- let popup_terminal_id = app
- .state
- .popup_pane
- .as_ref()
- .expect("popup installed")
- .terminal_id
- .clone();
- app.terminal_runtimes.remove(&popup_terminal_id);
-
- let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3);
- app.route_client_events(
- vec![
- crate::raw_input::RawInputEvent::Key(key.clone()),
- crate::raw_input::RawInputEvent::Key(
- key.clone()
- .with_kind(KeyEventKind::Repeat)
- .with_repeat_count(1),
- ),
- crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)),
- ],
- false,
- );
-
- assert!(app.state.popup_pane.is_none());
- assert!(input_rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn consumed_repeat_stays_suppressed_after_context_returns() {
- let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128);
- let page_up = physical_page_up(1);
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
- false,
- );
- let after_press = pane_scroll_offset(&app, pane_id);
-
- let (popup_runtime, mut popup_rx) =
- crate::terminal::TerminalRuntime::test_with_channel(40, 12);
- app.install_test_popup_runtime(popup_runtime);
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Repeat),
- )],
- false,
- );
- app.close_popup_pane();
- app.route_client_events(
- vec![
- crate::raw_input::RawInputEvent::Key(
- page_up.clone().with_kind(KeyEventKind::Repeat),
- ),
- crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
- ],
- false,
- );
-
- assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
- assert!(popup_rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn page_down_returns_to_bottom_after_page_up() {
- let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- assert!(pane_scroll_offset(&app, pane_id) > 0);
-
- app.handle_terminal_key_headless(TerminalKey::new(
- KeyCode::PageDown,
- KeyModifiers::empty(),
- ));
- assert_eq!(pane_scroll_offset(&app, pane_id), 0);
- }
-
- #[tokio::test]
- async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
- let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- let after_press = pane_scroll_offset(&app, pane_id);
- assert_eq!(after_press, pane_info.inner_rect.height as usize);
-
- app.handle_terminal_key_headless(
- TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())
- .with_kind(KeyEventKind::Release),
- );
-
- assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
- }
-
- #[tokio::test]
- async fn modified_page_up_does_not_host_scroll_plain_shell_pane() {
- let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));
-
- assert_eq!(pane_scroll_offset(&app, pane_id), 0);
- }
-
- #[tokio::test]
- async fn page_up_forwarded_to_mouse_reporting_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- let mut bytes = b"\x1b[?1002h".to_vec();
- bytes.extend_from_slice(&numbered_lines_bytes(64));
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &bytes,
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- assert_eq!(start_metrics.offset_from_bottom, 0);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
-
- let end_metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageUp");
- // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom.
- assert_eq!(end_metrics.offset_from_bottom, 0);
- }
-
- #[tokio::test]
- async fn page_up_forwarded_to_primary_screen_application_cursor_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- let mut bytes = b"\x1b[?1h".to_vec();
- bytes.extend_from_slice(&numbered_lines_bytes(64));
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &bytes,
- 4,
- );
- ws.tabs[0].runtimes.insert(pane_id, runtime);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- assert_eq!(start_metrics.offset_from_bottom, 0);
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
-
- let forwarded = input_rx.try_recv().expect("forwarded PageUp");
- assert_eq!(forwarded.as_ref(), b"\x1b[5~");
- let end_metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageUp");
- assert_eq!(end_metrics.offset_from_bottom, 0);
- }
-
- #[tokio::test]
- async fn page_up_scrolls_shell_like_decckm_with_bracketed_paste() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- // zsh enables DECCKM via smkx and bracketed paste together; bash/fish do not.
- let mut bytes = b"\x1b[?1h\x1b[?2004h".to_vec();
- bytes.extend_from_slice(&numbered_lines_bytes(64));
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &bytes,
- 4,
- );
- ws.tabs[0].runtimes.insert(pane_id, runtime);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
-
- assert!(
- input_rx.try_recv().is_err(),
- "PageUp should not reach the shell"
- );
- assert_eq!(
- pane_scroll_offset(&app, pane_id),
- info.inner_rect.height as usize
- );
- }
-}