From a08dfaef2f13873fc85dc9e1357235b8b0703a46 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Sun, 5 Apr 2026 02:24:57 +0300 Subject: [PATCH] feat: forward ghostty pane focus events on focus changes --- src/app/input.rs | 3 ++ src/app/mod.rs | 24 ++++++++++++++++ src/ghostty/mod.rs | 1 + src/pane.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/src/app/input.rs b/src/app/input.rs index d077909f..688a2a05 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -3366,6 +3366,7 @@ mod tests { alternate_screen: true, application_cursor: false, bracketed_paste: false, + focus_reporting: false, mouse_protocol_mode: vt100::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: vt100::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, @@ -3380,6 +3381,7 @@ mod tests { alternate_screen: true, application_cursor: false, bracketed_paste: false, + focus_reporting: false, mouse_protocol_mode: vt100::MouseProtocolMode::None, mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, @@ -3394,6 +3396,7 @@ mod tests { alternate_screen: false, application_cursor: false, bracketed_paste: false, + focus_reporting: false, mouse_protocol_mode: vt100::MouseProtocolMode::None, mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, diff --git a/src/app/mod.rs b/src/app/mod.rs index 914c6ca4..b53024b1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -738,7 +738,13 @@ impl App { return; } + #[cfg(feature = "ghostty-vt")] + if let Some((ws_idx, pane_id)) = self.last_focus { + self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); + } if let Some((ws_idx, pane_id)) = current_focus { + #[cfg(feature = "ghostty-vt")] + self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceFocused, data: crate::api::schema::EventData::WorkspaceFocused { @@ -770,6 +776,24 @@ impl App { self.last_focus = current_focus; } + #[cfg(feature = "ghostty-vt")] + fn send_pane_focus_event( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + event: crate::ghostty::FocusEvent, + ) { + let Some(runtime) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.runtime(pane_id)) + else { + return; + }; + runtime.try_send_focus_event(event); + } + fn find_pane( &self, pane_id: crate::layout::PaneId, diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 8c5e591d..718abd0c 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -121,6 +121,7 @@ pub const MOUSE_BUTTON_WHEEL_RIGHT: ffi::GhosttyMouseButton = ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SEVEN; pub const MODE_APPLICATION_CURSOR_KEYS: u16 = 1; +pub const MODE_FOCUS_EVENT: u16 = 1004; pub const MODE_BRACKETED_PASTE: u16 = 2004; pub const MODE_MOUSE_UTF8: u16 = 1005; pub const MODE_MOUSE_SGR: u16 = 1006; diff --git a/src/pane.rs b/src/pane.rs index bba39350..0ef46a8f 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -272,6 +272,7 @@ pub struct InputState { pub alternate_screen: bool, pub application_cursor: bool, pub bracketed_paste: bool, + pub focus_reporting: bool, pub mouse_protocol_mode: vt100::MouseProtocolMode, pub mouse_protocol_encoding: vt100::MouseProtocolEncoding, pub mouse_alternate_scroll: bool, @@ -593,6 +594,7 @@ impl VtPaneTerminal { alternate_screen: screen.alternate_screen(), application_cursor: screen.application_cursor(), bracketed_paste: screen.bracketed_paste(), + focus_reporting: false, mouse_protocol_mode: screen.mouse_protocol_mode(), mouse_protocol_encoding: screen.mouse_protocol_encoding(), mouse_alternate_scroll: self.mouse_alternate_scroll.load(Ordering::Relaxed), @@ -772,6 +774,10 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_BRACKETED_PASTE) .ok()?; + let focus_reporting = core + .terminal + .mode_get(crate::ghostty::MODE_FOCUS_EVENT) + .ok()?; let mouse_sgr = core.terminal.mode_get(crate::ghostty::MODE_MOUSE_SGR).ok()?; let mouse_utf8 = core.terminal.mode_get(crate::ghostty::MODE_MOUSE_UTF8).ok()?; let mouse_alternate_scroll = core @@ -800,6 +806,7 @@ impl GhosttyPaneTerminal { alternate_screen, application_cursor, bracketed_paste, + focus_reporting, mouse_protocol_mode, mouse_protocol_encoding, mouse_alternate_scroll, @@ -2008,6 +2015,25 @@ impl PaneRuntime { self.send_bytes(Bytes::from(payload)).await } + #[cfg(feature = "ghostty-vt")] + pub fn try_send_focus_event(&self, event: crate::ghostty::FocusEvent) -> bool { + if !self + .input_state() + .map(|state| state.focus_reporting) + .unwrap_or(false) + { + return false; + } + + let Ok(bytes) = crate::ghostty::encode_focus(event) else { + return false; + }; + if let Err(err) = self.try_send_bytes(Bytes::from(bytes)) { + warn!(err = %err, ?event, "failed to forward pane focus event"); + } + true + } + pub fn wheel_routing(&self) -> Option { let input_state = self.input_state()?; Some(if input_state.mouse_reporting_enabled() { @@ -2183,6 +2209,51 @@ mod tests { assert_eq!(encoded.as_deref(), Some(&b"\x1b[<36;5;7M"[..])); } + #[cfg(feature = "ghostty-vt")] + #[tokio::test] + async fn focus_events_are_forwarded_when_enabled() { + let (tx, mut rx) = mpsc::channel(4); + let (resize_tx, _resize_rx) = mpsc::channel(1); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.mode_set(crate::ghostty::MODE_FOCUS_EVENT, true).unwrap(); + let runtime = PaneRuntime { + terminal: Arc::new(PaneTerminal::Ghostty(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap())), + sender: tx, + resize_tx, + current_size: Cell::new((80, 24)), + child_pid: Arc::new(AtomicU32::new(0)), + kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), + detect_reset_notify: Arc::new(Notify::new()), + pending_release: Arc::new(Mutex::new(None)), + detect_handle: tokio::spawn(async {}).abort_handle(), + }; + + assert!(runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); + assert_eq!(rx.recv().await.unwrap(), Bytes::from_static(b"\x1b[I")); + } + + #[cfg(feature = "ghostty-vt")] + #[tokio::test] + async fn focus_events_are_suppressed_when_disabled() { + let (tx, mut rx) = mpsc::channel(4); + let (resize_tx, _resize_rx) = mpsc::channel(1); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let runtime = PaneRuntime { + terminal: Arc::new(PaneTerminal::Ghostty(GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap())), + sender: tx, + resize_tx, + current_size: Cell::new((80, 24)), + child_pid: Arc::new(AtomicU32::new(0)), + kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), + detect_reset_notify: Arc::new(Notify::new()), + pending_release: Arc::new(Mutex::new(None)), + detect_handle: tokio::spawn(async {}).abort_handle(), + }; + + assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained)); + assert!(tokio::time::timeout(std::time::Duration::from_millis(10), rx.recv()).await.is_err()); + } + #[test] fn recent_text_reconstructs_scrollback_tail() { let responses = PtyResponses::new();