aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 03:58:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-15 04:02:14 +0300
commit2c7c8beb079c5fd58a2e53efd2058df050ad9f55 (patch)
tree6756f671dc728d915b1d6ebececa8cf7f70b66fb /src/app/input
parent88370e1516182ab519d22a12fa82d5780a40fee6 (diff)
feat: add floating popup panes
refs #1125
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs74
-rw-r--r--src/app/input/navigate.rs20
-rw-r--r--src/app/input/terminal.rs274
3 files changed, 338 insertions, 30 deletions
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<std::path::PathBuf>) {
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]