diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-14 02:50:00 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-14 02:50:00 +0300 |
| commit | ed4253d6031c9a3b12ddd6dfc7d7bf39480ae761 (patch) | |
| tree | 315cea3ee7b5ad3a7c3f9fb31272064a0eed5dd2 /src/app | |
| parent | 287bf51640f8e1bed227cb27d93377d962851196 (diff) | |
feat: add tmux-style mouse capture config
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 1 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 48 | ||||
| -rw-r--r-- | src/app/mod.rs | 29 | ||||
| -rw-r--r-- | src/app/runtime.rs | 6 | ||||
| -rw-r--r-- | src/app/state.rs | 18 |
5 files changed, 99 insertions, 3 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 6263679c..9477ca15 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -441,6 +441,7 @@ impl AppState { area, self.tab_scroll, self.tab_scroll_follow_active, + self.mouse_capture, ); self.tab_scroll = layout.scroll; self.view.tab_hit_areas = layout.tab_hit_areas; diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 779af3ed..9b4055e5 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -25,6 +25,28 @@ use super::{ }; impl AppState { + pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) { + if self.mode != Mode::Terminal { + return; + } + let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { + return; + }; + + match mouse.kind { + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.forward_pane_reported_wheel(&info, mouse); + } + MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { + self.forward_pane_mouse_button(&info, mouse); + } + MouseEventKind::Moved => {} + } + } + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); @@ -1118,6 +1140,32 @@ impl AppState { true } + fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + return false; + }; + let Some(rt) = ws.runtimes.get(&info.id) else { + return false; + }; + if !rt + .input_state() + .is_some_and(crate::pane::InputState::mouse_reporting_enabled) + { + return false; + } + rt.scroll_reset(); + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else { + warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); + return true; + }; + if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); + } + true + } + pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { return false; diff --git a/src/app/mod.rs b/src/app/mod.rs index 8ef9d8f6..b56e8fc9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -33,7 +33,10 @@ const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); -use crossterm::terminal; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, terminal, +}; use ratatui::layout::Rect; use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; @@ -364,6 +367,7 @@ impl App { sidebar_collapsed: false, sidebar_section_split, agent_panel_scope, + mouse_capture: config.ui.mouse_capture, confirm_close: config.ui.confirm_close, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, kitty_graphics_enabled: config.advanced.kitty_graphics, @@ -449,6 +453,7 @@ impl App { self.query_host_terminal_theme(); let mut needs_render = true; + let mut host_mouse_capture_active = self.state.mouse_capture; while !self.state.should_quit { if self.render_dirty.load(Ordering::Acquire) { @@ -497,6 +502,7 @@ impl App { let now = Instant::now(); self.sync_animation_timer(now); + self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); @@ -577,6 +583,20 @@ impl App { Ok(()) } + fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { + let desired = self.state.should_capture_host_mouse(); + if desired == *active { + return Ok(()); + } + if desired { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } + *active = desired; + Ok(()) + } + pub(crate) fn dismiss_release_notes(&mut self) { let preview = self .state @@ -683,6 +703,7 @@ impl App { if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { self.state.sidebar_width = config.ui.sidebar_width; } + self.state.mouse_capture = config.ui.mouse_capture; self.state.confirm_close = config.ui.confirm_close; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; @@ -805,7 +826,11 @@ impl App { } } crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse_event_headless(mouse); + if self.state.mouse_capture { + self.handle_mouse_event_headless(mouse); + } else { + self.state.handle_pane_mouse_only(mouse); + } } crate::raw_input::RawInputEvent::Paste(text) => { if self.state.mode == Mode::Terminal { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 3c67c306..6cdb6b43 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -87,7 +87,11 @@ impl App { true } crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse(mouse); + if self.state.mouse_capture { + self.handle_mouse(mouse); + } else { + self.state.handle_pane_mouse_only(mouse); + } true } crate::raw_input::RawInputEvent::OuterFocusGained => { diff --git a/src/app/state.rs b/src/app/state.rs index c79fbc24..155851d7 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -709,6 +709,9 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_scope: AgentPanelScope, + /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only + /// captures mouse while the focused pane app requests mouse reporting. + pub mouse_capture: bool, pub confirm_close: bool, pub show_agent_labels_on_pane_borders: bool, pub kitty_graphics_enabled: bool, @@ -752,6 +755,20 @@ impl AppState { self.show_agent_labels_on_pane_borders } + pub fn focused_pane_requests_mouse_capture(&self) -> bool { + self.mode == Mode::Terminal + && self + .active + .and_then(|idx| self.workspaces.get(idx)) + .and_then(crate::workspace::Workspace::focused_runtime) + .and_then(crate::pane::PaneRuntime::input_state) + .is_some_and(crate::pane::InputState::mouse_reporting_enabled) + } + + pub fn should_capture_host_mouse(&self) -> bool { + self.mouse_capture || self.focused_pane_requests_mouse_capture() + } + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { key_matches(key, self.prefix_code, self.prefix_mods) } @@ -877,6 +894,7 @@ impl AppState { sidebar_collapsed: false, sidebar_section_split: 0.5, agent_panel_scope: AgentPanelScope::AllWorkspaces, + mouse_capture: true, confirm_close: true, show_agent_labels_on_pane_borders: false, kitty_graphics_enabled: false, |
