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 | |
| parent | 287bf51640f8e1bed227cb27d93377d962851196 (diff) | |
feat: add tmux-style mouse capture config
Diffstat (limited to 'src')
| -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 | ||||
| -rw-r--r-- | src/client/mod.rs | 32 | ||||
| -rw-r--r-- | src/config/model.rs | 16 | ||||
| -rw-r--r-- | src/main.rs | 11 | ||||
| -rw-r--r-- | src/server/headless.rs | 42 | ||||
| -rw-r--r-- | src/server/protocol.rs | 15 | ||||
| -rw-r--r-- | src/ui.rs | 1 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 2 | ||||
| -rw-r--r-- | src/ui/tabs.rs | 35 |
13 files changed, 242 insertions, 14 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, diff --git a/src/client/mod.rs b/src/client/mod.rs index 9679e95c..075eeb55 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -46,6 +46,8 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::ne struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. blit_encoder: blit::BlitEncoder, + /// Whether host mouse capture is currently active. + mouse_capture_active: bool, /// The terminal size we reported to the server in our last Hello/Resize. reported_size: (u16, u16), /// Client-local sound playback config, refreshed on server request. @@ -141,14 +143,18 @@ impl From<protocol::FramingError> for ClientError { // Terminal setup / restore // --------------------------------------------------------------------------- -/// Sets up the terminal for client mode (raw mode, mouse, keyboard enhancements). +/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements). /// /// Returns a guard that restores the terminal when dropped. -fn setup_terminal() -> io::Result<TerminalGuard> { +fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> { ratatui::init(); + if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } execute!( io::stdout(), - EnableMouseCapture, EnableBracketedPaste, EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) @@ -176,6 +182,14 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<() writer.flush() } +fn set_mouse_capture(enabled: bool) -> io::Result<()> { + if enabled { + execute!(io::stdout(), EnableMouseCapture) + } else { + execute!(io::stdout(), DisableMouseCapture) + } +} + fn restore_terminal_state(in_tmux: bool) { let _ = clear_received_kitty_graphics(&mut io::stdout()); @@ -337,7 +351,7 @@ pub fn run_client() -> io::Result<()> { // Now set up the terminal (raw mode, mouse, keyboard enhancements). // This must happen AFTER the handshake succeeds, so we don't leave // the terminal in raw mode if the server rejects us. - let _guard = setup_terminal().map_err(|err| { + let _guard = setup_terminal(false).map_err(|err| { eprintln!("herdr: failed to set up terminal: {err}"); err })?; @@ -372,6 +386,7 @@ pub fn run_client() -> io::Result<()> { should_quit, sound_config, kitty_graphics_enabled, + false, negotiated_encoding, ) .await @@ -416,10 +431,12 @@ async fn run_client_loop( should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, kitty_graphics_enabled: bool, + mouse_capture_active: bool, negotiated_encoding: RenderEncoding, ) -> Result<(), ClientError> { let mut state = ClientState { blit_encoder: blit::BlitEncoder::new(), + mouse_capture_active, reported_size: (cols, rows), sound_config, kitty_graphics_enabled, @@ -530,6 +547,13 @@ async fn run_client_loop( ServerMessage::ReloadSoundConfig => { reload_local_sound_config(&mut state.sound_config); } + ServerMessage::MouseCapture { enabled } => { + let desired = enabled; + if desired != state.mouse_capture_active { + set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?; + state.mouse_capture_active = desired; + } + } ServerMessage::Welcome { .. } => { debug!("received unexpected Welcome in main loop"); } diff --git a/src/config/model.rs b/src/config/model.rs index ffc117ed..a576418b 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -129,6 +129,8 @@ pub struct KeysConfig { #[serde(default)] pub struct UiConfig { pub sidebar_width: u16, + /// Capture mouse input for Herdr's mouse UI. Default: true. + pub mouse_capture: bool, /// Ask for confirmation before closing a workspace. Default: true. pub confirm_close: bool, /// Show agent labels in split pane borders when no manual pane label is set. Default: false. @@ -195,6 +197,7 @@ impl Default for UiConfig { fn default() -> Self { Self { sidebar_width: 26, + mouse_capture: true, confirm_close: true, show_agent_labels_on_pane_borders: false, agent_panel_scope: AgentPanelScopeConfig::All, @@ -273,6 +276,19 @@ show_agent_labels_on_pane_borders = true } #[test] + fn mouse_capture_default_on_and_parse() { + let default_config = Config::default(); + assert!(default_config.ui.mouse_capture); + + let toml = r#" +[ui] +mouse_capture = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.ui.mouse_capture); + } + + #[test] fn toast_config_parses() { let toml = r#" [ui.toast] diff --git a/src/main.rs b/src/main.rs index 7fcf624f..8a43746d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Sidebar width (auto-scaled based on workspace names, this sets the default) # sidebar_width = 26 +# Capture mouse input for Herdr's mouse UI. +# Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs. +# Pane apps like lazygit and btop can still receive mouse when they request it. +# mouse_capture = true + # Ask for confirmation before closing a workspace # confirm_close = true @@ -456,9 +461,13 @@ fn main() -> io::Result<()> { let result = rt.block_on(async { let mut terminal = ratatui::init(); + if config.ui.mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } execute!( io::stdout(), - EnableMouseCapture, EnableBracketedPaste, EnableFocusChange, PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) diff --git a/src/server/headless.rs b/src/server/headless.rs index f490ec45..3ff3e587 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -205,6 +205,8 @@ struct ClientConnection { graphics_cache: crate::kitty_graphics::HostGraphicsCache, /// Whether a render was skipped because the render channel was full. render_pending: bool, + /// Last host mouse capture mode sent to this client. + host_mouse_capture_active: Option<bool>, /// Channels for sending framed ServerMessage data to the client writer thread. writer: Option<ClientWriter>, } @@ -228,6 +230,7 @@ impl ClientConnection { render_state: ClientRenderState::new(render_encoding), graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), render_pending: false, + host_mouse_capture_active: None, writer, } } @@ -416,6 +419,7 @@ impl HeadlessServer { } self.drain_client_sound_config_reload_request(); + self.stream_host_mouse_capture_mode(); self.app.sync_headless_animation_timer(now); @@ -1423,6 +1427,44 @@ impl HeadlessServer { changed } + fn stream_host_mouse_capture_mode(&mut self) { + let enabled = self.app.state.should_capture_host_mouse(); + let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled }) + { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize mouse capture mode for clients"); + return; + } + }; + + let mut broken_clients: Vec<u64> = Vec::new(); + for (&client_id, client) in &mut self.clients { + if client.host_mouse_capture_active == Some(enabled) { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + if writer.control.send(serialized.clone()).is_err() { + debug!( + client_id, + "client writer channel closed during mouse capture update" + ); + broken_clients.push(client_id); + continue; + } + client.host_mouse_capture_active = Some(enabled); + } + + for client_id in broken_clients { + let foreground_changed = self.remove_client(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + } + } + /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. fn render_and_stream(&mut self) { diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 8748c41c..e0a3c4a0 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -294,6 +294,12 @@ pub enum ServerMessage { /// Client-local sound config changed on disk; refresh it without reconnecting. ReloadSoundConfig, + + /// Whether the client should currently capture host mouse input. + MouseCapture { + /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. + enabled: bool, + }, } // --------------------------------------------------------------------------- @@ -799,6 +805,15 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn server_mouse_capture_roundtrip() { + let msg = ServerMessage::MouseCapture { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Framing ---- #[test] @@ -188,6 +188,7 @@ fn compute_view_internal( tab_bar_rect, app.tab_scroll, app.tab_scroll_follow_active, + app.mouse_capture, ) }) .unwrap_or_default(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 8a104dba..f10c4241 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -716,7 +716,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); } - if list_bottom > area.y { + if app.mouse_capture && list_bottom > area.y { let new_rect = app.sidebar_new_button_rect(); frame.render_widget( Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))), diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index a6ce0e68..ce859432 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -98,11 +98,28 @@ pub(crate) fn compute_tab_bar_view( area: Rect, current_scroll: usize, follow_active: bool, + mouse_chrome: bool, ) -> TabBarView { if area.width == 0 || area.height == 0 { return TabBarView::default(); } + if !mouse_chrome { + let max_scroll = max_tab_scroll(ws, area); + let scroll = if follow_active { + centered_tab_scroll(ws, area).min(max_scroll) + } else { + current_scroll.min(max_scroll) + }; + return TabBarView { + scroll, + tab_hit_areas: layout_tab_hit_areas(ws, area, scroll), + scroll_left_hit_area: Rect::default(), + scroll_right_hit_area: Rect::default(), + new_tab_hit_area: Rect::default(), + }; + } + let area_right = area.x + area.width; let all_tabs_area = Rect::new( area.x, @@ -253,7 +270,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0 && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()); - if app.view.tab_scroll_left_hit_area.width > 0 { + if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { let style = if can_scroll_left { Style::default().fg(p.overlay1).bg(p.surface0) } else { @@ -268,7 +285,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { ); } - if app.view.tab_scroll_right_hit_area.width > 0 { + if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { let style = if can_scroll_right { Style::default().fg(p.overlay1).bg(p.surface0) } else { @@ -330,7 +347,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } } - if app.view.new_tab_hit_area.width > 0 { + if app.mouse_capture && app.view.new_tab_hit_area.width > 0 { frame.render_widget( Paragraph::new(" + ").style(Style::default().fg(p.overlay1)), app.view.new_tab_hit_area, @@ -338,7 +355,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } if first_visible_idx.is_some_and(|idx| idx > 0) { - let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width; + let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 { + app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width + } else { + area.x + }; if x < area.x + area.width { frame.buffer_mut()[(x, area.y)] .set_symbol("…") @@ -346,7 +367,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } } if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { - let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1); + let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { + app.view.tab_scroll_right_hit_area.x.saturating_sub(1) + } else { + area.x + area.width.saturating_sub(1) + }; if x >= area.x && x < area.x + area.width { frame.buffer_mut()[(x, area.y)] .set_symbol("…") |
