aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 02:50:00 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 02:50:00 +0300
commited4253d6031c9a3b12ddd6dfc7d7bf39480ae761 (patch)
tree315cea3ee7b5ad3a7c3f9fb31272064a0eed5dd2 /src/app
parent287bf51640f8e1bed227cb27d93377d962851196 (diff)
feat: add tmux-style mouse capture config
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/input/mouse.rs48
-rw-r--r--src/app/mod.rs29
-rw-r--r--src/app/runtime.rs6
-rw-r--r--src/app/state.rs18
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,