fix: forward outer focus events to panes (#1388)
* fix: forward outer focus events to panes refs #1337 * fix: preserve ordered focus forwarding refs #1337 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
parent
3a8490f651
commit
2e5da968ea
|
|
@ -7,6 +7,7 @@
|
|||
- Added maki detection with idle, working, and blocked screen states. (#1301, thanks @tontinton)
|
||||
|
||||
### Fixed
|
||||
- Outer-terminal focus gained and lost reports now reach the focused pane when its application enables focus reporting, restoring Neovim file autoreload and other focus-aware terminal behavior. (#1337)
|
||||
- Native Windows servers now detach from the terminal console that launched them, so closing WezTerm, Windows Terminal, or another host terminal no longer stops persistent pane processes. (#1329)
|
||||
- Windows API clients now remain connected while waiting for initial named-pipe request bytes, so `status server`, `api snapshot`, and other socket commands no longer intermittently fail with BrokenPipe. (#1279)
|
||||
- `herdr --remote` now installs remote helper binaries without routing the binary stream through a multiline `/bin/sh -c` command, fixing installs for non-POSIX login shells such as xonsh. (#1203, thanks @nhumrich)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
120
src/app/mod.rs
120
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);
|
||||
|
|
@ -3262,6 +3266,118 @@ mod tests {
|
|||
assert!(!app.full_redraw_pending);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue