From 2c7c8beb079c5fd58a2e53efd2058df050ad9f55 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 15 Jul 2026 03:58:07 +0300 Subject: feat: add floating popup panes refs #1125 --- src/app/input/mod.rs | 74 +++++++++++++ src/app/input/navigate.rs | 20 ++++ src/app/input/terminal.rs | 274 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 338 insertions(+), 30 deletions(-) (limited to 'src/app/input') diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 421b3738..21e9a49b 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -1,6 +1,8 @@ //! Input handling — translates crossterm key/mouse events into state mutations. +use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use tracing::warn; use crate::app::PaneClickState; use crate::input::TerminalKey; @@ -70,6 +72,10 @@ use super::App; impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { + if self.state.popup_pane.is_some() { + self.handle_terminal_key(key).await; + return; + } let key_event = key.as_key_event(); if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) { if let Some(text) = crate::platform::read_clipboard_text() { @@ -111,6 +117,14 @@ impl App { } pub(super) async fn handle_paste(&mut self, text: String) { + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.send_paste(text).await; + } else { + self.close_popup_pane(); + } + return; + } if self.state.mode != Mode::Terminal { self.paste_into_active_text_input(&text); return; @@ -239,6 +253,10 @@ impl App { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.state.popup_pane.is_some() { + self.handle_popup_mouse(mouse); + return; + } if self.handle_overlay_mouse(mouse) { return; } @@ -373,6 +391,62 @@ impl App { } } + fn handle_popup_mouse(&mut self, mouse: MouseEvent) { + let Some((_outer, inner)) = + crate::ui::popup_pane_rects(&self.state, self.state.view.terminal_area) + else { + return; + }; + if mouse.column < inner.x + || mouse.column >= inner.x.saturating_add(inner.width) + || mouse.row < inner.y + || mouse.row >= inner.y.saturating_add(inner.height) + { + return; + } + let Some(rt) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let column = mouse.column.saturating_sub(inner.x); + let row = mouse.row.saturating_sub(inner.y); + let bytes = match mouse.kind { + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => match rt.wheel_routing() { + Some(crate::pane::WheelRouting::MouseReport) => { + rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) + } + Some(crate::pane::WheelRouting::AlternateScroll) => { + rt.encode_alternate_scroll(mouse.kind) + } + Some(crate::pane::WheelRouting::HostScroll) | None => { + let lines_per_notch = self.state.mouse_scroll_lines; + match mouse.kind { + MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), + MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), + _ => {} + } + return; + } + }, + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) + } + MouseEventKind::Moved => { + rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) + } + }; + let Some(bytes) = bytes else { + return; + }; + rt.scroll_reset(); + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(err = %err, kind = ?mouse.kind, "failed to forward popup mouse event"); + } + } + fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool { if self.state.mode != Mode::Terminal || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 7f98f530..33177624 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -778,6 +778,7 @@ impl App { crate::config::CustomCommandAction::Pane => { self.spawn_pane_command(&binding.command, Vec::new()) } + crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(&binding), crate::config::CustomCommandAction::PluginAction => self .invoke_plugin_action_from_keybind(binding.command.clone()) .map_err(std::io::Error::other), @@ -798,6 +799,21 @@ impl App { } } + fn spawn_custom_popup_command( + &mut self, + binding: &crate::config::CustomCommandKeybind, + ) -> io::Result<()> { + self.spawn_popup_shell_command( + &binding.command, + None, + self.custom_command_env().0, + crate::app::popup::PopupGeometry { + width: binding.width, + height: binding.height, + }, + ) + } + fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { let mut env = vec![( crate::api::SOCKET_PATH_ENV_VAR.to_string(), @@ -2956,6 +2972,8 @@ navigate_pane_down = "ctrl+j" command, action: crate::config::CustomCommandAction::Shell, description: None, + width: None, + height: None, }]; app.handle_key(TerminalKey::new( @@ -3048,6 +3066,8 @@ navigate_pane_down = "ctrl+j" command, action: crate::config::CustomCommandAction::Pane, description: None, + width: None, + height: None, }]; app.handle_key(TerminalKey::new( diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index dd1e811f..e8e6495e 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -13,12 +13,31 @@ struct PreparedPaneInput { bytes: Bytes, } +enum PreparedPopupInput { + NotOpen, + Consumed, + 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) { + match self.prepare_popup_key_forward(key) { + PreparedPopupInput::NotOpen => {} + PreparedPopupInput::Consumed => return, + PreparedPopupInput::Bytes(bytes) => { + let Some(runtime) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let _ = runtime.try_send_bytes(bytes); + return; + } + } + let Some(input) = self.prepare_terminal_key_forward(key) else { return; }; @@ -192,7 +211,38 @@ impl App { }) } + fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput { + if self.state.popup_pane.is_none() { + return PreparedPopupInput::NotOpen; + } + let Some(rt) = self.popup_runtime() else { + self.close_popup_pane(); + return PreparedPopupInput::Consumed; + }; + rt.scroll_reset(); + let bytes = rt.encode_terminal_key(key); + self.state.mode = Mode::Terminal; + if bytes.is_empty() { + PreparedPopupInput::Consumed + } else { + PreparedPopupInput::Bytes(Bytes::from(bytes)) + } + } + pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) { + match self.prepare_popup_key_forward(key) { + PreparedPopupInput::NotOpen => {} + PreparedPopupInput::Consumed => return, + PreparedPopupInput::Bytes(bytes) => { + let Some(runtime) = self.popup_runtime() else { + self.close_popup_pane(); + return; + }; + let _ = runtime.send_bytes(bytes).await; + return; + } + } + let Some(input) = self.prepare_terminal_key_forward(key) else { return; }; @@ -213,6 +263,45 @@ mod tests { 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, + 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"); @@ -1093,6 +1182,8 @@ mod tests { command, action: crate::config::CustomCommandAction::Shell, description: None, + width: None, + height: None, }]; app.handle_terminal_key(TerminalKey::new( @@ -1109,33 +1200,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn direct_custom_pane_command_opens_overlay_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.default_shell = "/usr/bin/true".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, - 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; + let mut app = app_with_spawned_workspace(); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), @@ -1143,6 +1208,8 @@ mod tests { command: "printf direct-pane".into(), action: crate::config::CustomCommandAction::Pane, description: None, + width: None, + height: None, }]; app.handle_terminal_key(TerminalKey::new( @@ -1155,10 +1222,157 @@ mod tests { assert!(app.state.workspaces[0].tabs[0].zoomed); assert_eq!(app.state.mode, Mode::Terminal); - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); + 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] -- cgit v1.2.3