diff options
Diffstat (limited to 'src/app/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 501 |
1 files changed, 486 insertions, 15 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 8a9e7389..b56c128c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -135,8 +135,9 @@ pub struct App { pub(crate) detached_custom_command_children: Vec<std::process::Child>, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option<Instant>, - pub(crate) suppressed_repeat_keys: - HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(crate) pressed_terminal_keys: + HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>, + pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>, pub render_notify: Arc<Notify>, pub render_dirty: Arc<AtomicBool>, pub(crate) full_redraw_pending: bool, @@ -197,10 +198,25 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) { } } -fn repeat_key_identity( +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TerminalInputTarget { + terminal_id: crate::terminal::TerminalId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PressedTerminalKey { + target: TerminalInputTarget, + key: crate::input::TerminalKey, +} + +pub(crate) type InputSourceId = u64; +const LOCAL_INPUT_SOURCE: InputSourceId = 0; + +fn pressed_key_identity( + source_id: InputSourceId, key: &crate::input::TerminalKey, -) -> (crossterm::event::KeyCode, crossterm::event::KeyModifiers) { - (key.code, key.modifiers) +) -> (InputSourceId, crossterm::event::KeyCode) { + (source_id, key.code) } fn auto_updates_enabled(no_session: bool) -> bool { @@ -746,6 +762,7 @@ impl App { selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, + pressed_terminal_keys: HashMap::new(), suppressed_repeat_keys: HashSet::new(), api_rx, event_hub, @@ -895,6 +912,7 @@ impl App { let mut needs_render = true; let mut host_mouse_capture_active = self.state.mouse_capture; + let mut host_keyboard_report_all_active = false; while !self.state.should_quit { self.reap_finished_custom_commands(); @@ -1021,6 +1039,7 @@ impl App { let now = Instant::now(); self.sync_animation_timer(now); self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; + self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); @@ -1137,6 +1156,16 @@ impl App { Ok(()) } + fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> { + let desired = self.host_keyboard_report_all_requested(); + if desired == *active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?; + *active = desired; + Ok(()) + } + fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { let desired = self .state @@ -1571,34 +1600,65 @@ impl App { events: Vec<crate::raw_input::RawInputEvent>, apply_host_terminal_theme: bool, ) { + self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); + } + + pub(crate) fn route_client_events_from( + &mut self, + source_id: InputSourceId, + events: Vec<crate::raw_input::RawInputEvent>, + apply_host_terminal_theme: bool, + ) { for event in events { let previous_mode = self.state.mode; match event { crate::raw_input::RawInputEvent::Key(key) => { - let key_id = repeat_key_identity(&key); + let pressed_key_id = pressed_key_identity(source_id, &key); match key.kind { crossterm::event::KeyEventKind::Press => { if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal { - self.suppressed_repeat_keys.remove(&key_id); - self.handle_terminal_key_headless(key); + self.suppressed_repeat_keys.remove(&pressed_key_id); + if let Some(target) = self.handle_terminal_key_headless(key) { + if !key.is_text_commit { + self.pressed_terminal_keys.insert( + pressed_key_id, + PressedTerminalKey { target, key }, + ); + } + } else { + self.pressed_terminal_keys.remove(&pressed_key_id); + } } else { - self.suppressed_repeat_keys.insert(key_id); + self.pressed_terminal_keys.remove(&pressed_key_id); + self.suppressed_repeat_keys.insert(pressed_key_id); self.handle_non_terminal_key_headless(key); } } crossterm::event::KeyEventKind::Repeat => { - if (self.state.popup_pane.is_some() + if let Some(pressed) = + self.pressed_terminal_keys.get(&pressed_key_id).cloned() + { + if !self + .forward_terminal_key_to_target_headless(&pressed.target, key) + { + self.pressed_terminal_keys.remove(&pressed_key_id); + } + } else if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&key_id) + && !self.suppressed_repeat_keys.contains(&pressed_key_id) { - self.handle_terminal_key_headless(key); + let _ = self.handle_terminal_key_headless(key); } - // Repeats in non-terminal modes are ignored - // (same as monolithic behavior). } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&key_id); + self.suppressed_repeat_keys.remove(&pressed_key_id); + if let Some(pressed) = + self.pressed_terminal_keys.remove(&pressed_key_id) + { + let _ = self + .forward_terminal_key_to_target_headless(&pressed.target, key); + } } } } @@ -1634,6 +1694,7 @@ impl App { self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); } crate::raw_input::RawInputEvent::OuterFocusLost => { + self.release_input_source_headless(source_id); self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); } crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { @@ -1652,6 +1713,10 @@ impl App { } } + pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { + self.release_input_source_headless(source_id); + } + /// Handles a key event in non-terminal mode for the headless server. /// /// Uses the standalone handler functions that work on `&mut AppState` @@ -3299,6 +3364,43 @@ mod tests { } #[tokio::test] + async fn monolithic_input_forwards_report_all_printable_event_kinds() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + for kind in [ + KeyEventKind::Press, + KeyEventKind::Repeat, + KeyEventKind::Release, + ] { + app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) + .await; + } + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:2u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] async fn outer_focus_gained_marks_visible_done_panes_seen() { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); @@ -4909,6 +5011,375 @@ last_pane = "prefix+tab" } #[tokio::test] + async fn host_report_all_follows_the_focused_terminal_protocol() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, _rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + assert!(app.host_keyboard_report_all_requested()); + + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) + .unwrap() + .test_process_pty_bytes(b"\x1b[<u"); + assert!(!app.host_keyboard_report_all_requested()); + + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) + .unwrap() + .test_process_pty_bytes(b"\x1b[>15u"); + let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + let (other_runtime, _rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); + app.state.workspaces[0].tabs[0] + .runtimes + .insert(other_pane, other_runtime); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); + assert!(!app.host_keyboard_report_all_requested()); + + assert!(app.state.focus_pane_in_workspace(0, focused)); + app.state.mode = Mode::Prefix; + assert!(!app.host_keyboard_report_all_requested()); + } + + #[tokio::test] + async fn route_client_input_forwards_report_all_printable_event_kinds() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:2u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn committed_ime_text_bypasses_report_all_key_encoding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input("你".as_bytes().to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static("你".as_bytes()) + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"A".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn committed_text_does_not_erase_owned_physical_key() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[106u".to_vec()); + app.route_client_input(b"j".to_vec()); + app.route_client_input(b"\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.try_recv().expect("physical press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("committed text"), + bytes::Bytes::from_static(b"j") + ); + assert_eq!( + rx.try_recv().expect("physical release"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn outer_focus_loss_releases_owned_report_all_keys() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events( + vec![ + raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + ), + crate::raw_input::RawInputEvent::OuterFocusLost, + ], + false, + ); + + assert_eq!( + rx.try_recv().expect("forwarded press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("synthetic release on focus loss"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn disconnected_input_source_releases_owned_report_all_keys() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events_from( + 42, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + app.clear_input_source(42); + + assert_eq!( + rx.try_recv().expect("forwarded press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("synthetic release on disconnect"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn report_all_repeat_and_release_return_to_the_pressed_pane() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + let (other_runtime, mut other_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[106u".to_vec()); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:2u") + ); + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(pressed_rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn report_all_key_ownership_is_isolated_by_client() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let first_pane = workspace.focused_pane_id().unwrap(); + let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(first_pane); + let (first_runtime, mut first_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + let (second_runtime, mut second_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + workspace.tabs[0].runtimes.insert(first_pane, first_runtime); + workspace.tabs[0] + .runtimes + .insert(second_pane, second_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events_from( + 1, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, second_pane)); + app.route_client_events_from( + 2, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + app.route_client_events_from( + 1, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Release, + )], + false, + ); + app.route_client_events_from( + 2, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Release, + )], + false, + ); + + for rx in [&mut first_rx, &mut second_rx] { + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + } + + #[tokio::test] + async fn report_all_release_survives_modifier_release_order() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106:74;2:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn route_client_input_does_not_forward_release_for_consumed_prefix() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); + + assert_eq!(app.state.mode, Mode::Prefix); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); |
