aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs23
-rw-r--r--src/app/mod.rs120
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/server/headless.rs243
4 files changed, 384 insertions, 4 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index c013d271..d6d657b7 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -765,6 +765,17 @@ impl App {
}
pub(crate) fn sync_focus_events(&mut self) {
+ self.sync_focus_events_with_outer_event(None);
+ }
+
+ pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) {
+ self.sync_focus_events_with_outer_event(Some(event));
+ }
+
+ fn sync_focus_events_with_outer_event(
+ &mut self,
+ outer_event: Option<crate::ghostty::FocusEvent>,
+ ) {
let current_focus = self.state.active.and_then(|idx| {
self.state
.workspaces
@@ -772,6 +783,9 @@ impl App {
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
if current_focus == self.last_focus {
+ if let (Some((ws_idx, pane_id)), Some(event)) = (current_focus, outer_event) {
+ self.send_pane_focus_event(ws_idx, pane_id, event);
+ }
return;
}
@@ -779,7 +793,14 @@ impl App {
self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost);
}
if let Some((ws_idx, pane_id)) = current_focus {
- self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained);
+ let event = outer_event.unwrap_or_else(|| {
+ if self.state.outer_terminal_focus == Some(false) {
+ crate::ghostty::FocusEvent::Lost
+ } else {
+ crate::ghostty::FocusEvent::Gained
+ }
+ });
+ self.send_pane_focus_event(ws_idx, pane_id, event);
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::WorkspaceFocused,
data: crate::api::schema::EventData::WorkspaceFocused {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 16cfeafb..6a7470be 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1617,8 +1617,12 @@ impl App {
}
}
}
- crate::raw_input::RawInputEvent::OuterFocusGained
- | crate::raw_input::RawInputEvent::OuterFocusLost => {}
+ crate::raw_input::RawInputEvent::OuterFocusGained => {
+ self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
+ }
+ crate::raw_input::RawInputEvent::OuterFocusLost => {
+ self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
+ }
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
if apply_host_terminal_theme {
self.update_host_terminal_theme(kind, color);
@@ -3263,6 +3267,118 @@ mod tests {
}
#[tokio::test]
+ async fn monolithic_outer_focus_events_reach_reporting_pane() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("focus-reporting");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ workspace.insert_test_runtime(pane_id, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ assert!(
+ app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
+ .await
+ );
+ assert_eq!(
+ input_rx
+ .recv()
+ .await
+ .expect("forwarded focus gained report"),
+ bytes::Bytes::from_static(b"\x1b[I")
+ );
+
+ assert!(
+ !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost)
+ .await
+ );
+ assert_eq!(
+ input_rx.recv().await.expect("forwarded focus lost report"),
+ bytes::Bytes::from_static(b"\x1b[O")
+ );
+ }
+
+ #[tokio::test]
+ async fn outer_focus_events_reconcile_pending_pane_focus() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("focus-transition");
+ let previous_pane = workspace.tabs[0].root_pane;
+ let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.tabs[0].layout.focus_pane(previous_pane);
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[?1004h",
+ 4,
+ );
+ workspace.insert_test_runtime(next_pane, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.last_focus = Some((0, previous_pane));
+
+ assert!(app.state.focus_pane_in_workspace(0, next_pane));
+ assert!(
+ !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost)
+ .await
+ );
+ app.sync_focus_events();
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[O")
+ );
+ assert!(input_rx.try_recv().is_err());
+
+ assert!(app.state.focus_pane_in_workspace(0, previous_pane));
+ app.sync_focus_events();
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[O")
+ );
+
+ assert!(app.state.focus_pane_in_workspace(0, next_pane));
+ app.sync_focus_events();
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[O")
+ );
+
+ assert!(
+ app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
+ .await
+ );
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[I")
+ );
+
+ assert!(app.state.focus_pane_in_workspace(0, previous_pane));
+ app.sync_focus_events();
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[O")
+ );
+ assert!(app.state.focus_pane_in_workspace(0, next_pane));
+ app.sync_focus_events();
+ assert_eq!(
+ input_rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[I")
+ );
+ }
+
+ #[tokio::test]
async fn repeat_key_events_are_ignored_outside_terminal_mode() {
let mut app = test_app();
app.state.mode = Mode::ReleaseNotes;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 25275cd7..6d63f139 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -180,6 +180,7 @@ impl App {
true
}
crate::raw_input::RawInputEvent::OuterFocusGained => {
+ self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
if self.state.redraw_on_focus_gained {
self.request_full_redraw();
}
@@ -188,6 +189,7 @@ impl App {
true
}
crate::raw_input::RawInputEvent::OuterFocusLost => {
+ self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
self.state.outer_terminal_focus = Some(false);
false
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c965d8fb..bc64f0a8 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2414,6 +2414,11 @@ impl HeadlessServer {
client_id: u64,
events: Vec<crate::raw_input::RawInputEvent>,
) -> bool {
+ let source_was_foreground = self.foreground_client_id == Some(client_id);
+ let source_is_full_app = self
+ .clients
+ .get(&client_id)
+ .is_some_and(ClientConnection::is_full_app_client);
let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
&events,
self.app.state.redraw_on_focus_gained,
@@ -2430,7 +2435,10 @@ impl HeadlessServer {
client.request_semantic_redraw_after_input();
}
}
- self.update_client_outer_focus_from_events(client_id, &events);
+ if source_is_full_app {
+ self.update_client_outer_focus_from_events(client_id, &events);
+ }
+ let events = events_for_app_routing(events, source_was_foreground, source_is_full_app);
let interaction = events_include_interaction(&events);
let foreground_changed = if interaction {
self.promote_client_to_foreground(client_id)
@@ -3819,6 +3827,36 @@ impl HeadlessServer {
}
}
+fn events_for_app_routing(
+ events: Vec<crate::raw_input::RawInputEvent>,
+ mut source_is_foreground: bool,
+ source_is_full_app: bool,
+) -> Vec<crate::raw_input::RawInputEvent> {
+ events
+ .into_iter()
+ .filter_map(|event| match event {
+ crate::raw_input::RawInputEvent::OuterFocusGained
+ | crate::raw_input::RawInputEvent::OuterFocusLost
+ if !source_is_full_app =>
+ {
+ None
+ }
+ crate::raw_input::RawInputEvent::OuterFocusGained => {
+ source_is_foreground = true;
+ Some(event)
+ }
+ crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None,
+ crate::raw_input::RawInputEvent::Key(_)
+ | crate::raw_input::RawInputEvent::Mouse(_)
+ | crate::raw_input::RawInputEvent::Paste(_) => {
+ source_is_foreground = true;
+ Some(event)
+ }
+ _ => Some(event),
+ })
+ .collect()
+}
+
impl Drop for HeadlessServer {
fn drop(&mut self) {
let staged_files = self
@@ -6473,6 +6511,209 @@ next_tab = ""
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
}
+ #[tokio::test]
+ async fn foreground_focus_gained_reaches_pane_with_focus_reporting() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
+
+ server.clients.insert(1, test_app_client(Some(false), 1));
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[I".to_vec(),
+ }));
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded focus gained report"),
+ Bytes::from_static(b"\x1b[I")
+ );
+
+ assert!(!server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[O".to_vec(),
+ }));
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded focus lost report"),
+ Bytes::from_static(b"\x1b[O")
+ );
+ }
+
+ #[tokio::test]
+ async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"");
+ server.clients.insert(1, test_app_client(Some(false), 1));
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[I".to_vec(),
+ }));
+ assert!(matches!(
+ input_rx.try_recv(),
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty)
+ ));
+ }
+
+ #[tokio::test]
+ async fn background_focus_batch_only_forwards_events_after_promotion() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
+ server.clients.insert(1, test_app_client(Some(true), 1));
+ server.clients.insert(2, test_app_client(Some(false), 2));
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 2,
+ data: b"\x1b[O\x1b[I".to_vec(),
+ }));
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ assert_eq!(
+ input_rx
+ .try_recv()
+ .expect("focus gained after client promotion"),
+ Bytes::from_static(b"\x1b[I")
+ );
+ assert!(matches!(
+ input_rx.try_recv(),
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty)
+ ));
+ }
+
+ #[tokio::test]
+ async fn structured_outer_focus_events_reach_reporting_pane() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
+ server.clients.insert(1, test_app_client(Some(true), 1));
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![
+ crate::protocol::ClientInputEvent::FocusGained,
+ crate::protocol::ClientInputEvent::FocusLost,
+ ],
+ }));
+ assert_eq!(
+ input_rx.try_recv().expect("structured focus gained report"),
+ Bytes::from_static(b"\x1b[I")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("structured focus lost report"),
+ Bytes::from_static(b"\x1b[O")
+ );
+ }
+
+ #[tokio::test]
+ async fn background_key_makes_later_focus_lost_eligible() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
+ server.clients.insert(1, test_app_client(Some(true), 1));
+ server.clients.insert(2, test_app_client(Some(true), 2));
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 2,
+ events: vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ },
+ crate::protocol::ClientInputEvent::FocusLost,
+ ],
+ }));
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(
+ input_rx.try_recv().expect("focus lost after promotion"),
+ Bytes::from_static(b"\x1b[O")
+ );
+ }
+
+ #[tokio::test]
+ async fn structured_non_app_focus_is_ignored_without_suppressing_keys() {
+ let mut server = test_headless_server();
+ let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
+ server.clients.insert(1, test_app_client(Some(true), 1));
+
+ let mut attached = test_app_client(Some(false), 2);
+ attached.mode = ClientConnectionMode::TerminalAttach {
+ terminal_id: "attached".to_owned(),
+ };
+ server.clients.insert(2, attached);
+
+ let mut pending = test_app_client(Some(false), 3);
+ pending.pending_terminal_attach = true;
+ server.clients.insert(3, pending);
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ for client_id in [2, 3] {
+ assert!(!server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id,
+ events: vec![crate::protocol::ClientInputEvent::FocusGained],
+ }));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false));
+ }
+
+ assert!(matches!(
+ input_rx.try_recv(),
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty)
+ ));
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 3,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('x'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ }],
+ }));
+ assert_eq!(server.foreground_client_id, Some(3));
+ }
+
+ fn install_focused_test_runtime(
+ server: &mut HeadlessServer,
+ terminal_bytes: &[u8],
+ ) -> tokio::sync::mpsc::Receiver<Bytes> {
+ let mut workspace = crate::workspace::Workspace::test_new("focus-reporting");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ terminal_bytes,
+ 4,
+ );
+ workspace.insert_test_runtime(pane_id, runtime);
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+ input_rx
+ }
+
+ fn test_app_client(outer_terminal_focus: Option<bool>, last_activity: u64) -> ClientConnection {
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus,
+ last_activity,
+ RenderEncoding::SemanticFrame,
+ None,
+ )
+ }
+
#[test]
fn foreground_client_focus_event_updates_app_focus_state() {
let mut server = test_headless_server();