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.rs440
1 files changed, 440 insertions, 0 deletions
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
new file mode 100644
index 00000000..910a49bd
--- /dev/null
+++ b/src/app/input/terminal.rs
@@ -0,0 +1,440 @@
+use bytes::Bytes;
+use crossterm::event::KeyCode;
+use tracing::{debug, warn};
+
+use crate::{
+ app::{App, Mode},
+ input::TerminalKey,
+};
+
+struct PreparedPaneInput {
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ bytes: Bytes,
+}
+
+fn is_modifier_only_key(code: &KeyCode) -> bool {
+ matches!(code, KeyCode::Modifier(_))
+}
+
+impl App {
+ pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.try_send_bytes(input.bytes);
+ }
+ }
+
+ fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
+ self.state.clear_selection();
+ self.state.update_dismissed = true;
+
+ let key_event = key.as_key_event();
+
+ if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ action = ?action,
+ "intercepted terminal direct navigation key before forwarding to pane"
+ );
+ super::navigate::execute_navigate_action(&mut self.state, action);
+ return None;
+ }
+
+ if self.state.is_prefix(&key_event) {
+ self.state.mode = Mode::Navigate;
+ 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 rt = ws.runtimes.get(&pane_id)?;
+ rt.scroll_reset();
+ let protocol = rt.keyboard_protocol();
+ let bytes = rt.encode_terminal_key(key);
+
+ 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,
+ bytes: Bytes::from(bytes),
+ })
+ }
+
+ pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.send_bytes(input.bytes).await;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ use super::*;
+ use crate::{config::Config, workspace::Workspace};
+
+ #[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.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::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.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::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.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::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.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::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 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.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::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.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::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.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::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 clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() {
+ 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::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ 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;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() {
+ 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::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ 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;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ assert!(app.state.context_menu.is_some());
+ }
+
+ #[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,
+ 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 = Some((KeyCode::Char('h'), KeyModifiers::ALT));
+ app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+
+ 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);
+ }
+}