From 9251522a69665889edc8c7972814e00bd7ad61f6 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 14 Oct 2026 13:30:14 +0100 Subject: [PATCH] fix(input): match layer keyboard focus to fullscreen visibility (#49) * fix(input): require mapped layers for keyboard focus * fix(input): match layer keyboard focus to fullscreen visibility --------- Co-authored-by: Samuel Onoja --- compositor/src/handlers/layer_shell.rs | 7 +- compositor/src/input/layer_focus_tests.rs | 295 +++++++++++++++++++++- compositor/src/overview.rs | 1 + compositor/src/render/scene.rs | 2 +- compositor/src/state/animation.rs | 4 + compositor/src/state/floating.rs | 8 + compositor/src/state/hit_testing.rs | 2 +- compositor/src/state/layout.rs | 1 + 8 files changed, 314 insertions(+), 6 deletions(-) diff --git a/compositor/src/handlers/layer_shell.rs b/compositor/src/handlers/layer_shell.rs index 2296573..105d9b5 100644 --- a/compositor/src/handlers/layer_shell.rs +++ b/compositor/src/handlers/layer_shell.rs @@ -205,9 +205,10 @@ impl Ferese { }) .unwrap_or(false) && self.space.outputs().any(|output| { - layer_map_for_output(output) - .layers() - .any(|candidate| candidate == layer) + self.layer_visible_on_output(output, layer.layer()) + && layer_map_for_output(output) + .layers() + .any(|candidate| candidate == layer) }) } diff --git a/compositor/src/input/layer_focus_tests.rs b/compositor/src/input/layer_focus_tests.rs index bd7def5..724f228 100644 --- a/compositor/src/input/layer_focus_tests.rs +++ b/compositor/src/input/layer_focus_tests.rs @@ -246,10 +246,14 @@ fn on_demand_layer_focus_follows_opening_and_outside_clicks() { } fn fixture() -> (Ferese, EventLoop<'static, Ferese>, Output) { + fixture_with_animations(false) +} + +fn fixture_with_animations(animations: bool) -> (Ferese, EventLoop<'static, Ferese>, Output) { let mut events = EventLoop::try_new().unwrap(); let mut config = crate::config::Config::default().runtime_config().unwrap(); config.wallpaper.path = None; - config.animations_enabled = false; + config.animations_enabled = animations; let mut state = Ferese::new( &mut events, smithay::reexports::wayland_server::Display::new().unwrap(), @@ -466,3 +470,292 @@ fn layer_mapping_commits_do_not_override_session_lock() { "layer destruction preserves lock-owned focus policy" ); } + +#[test] +fn fullscreen_hidden_top_commits_cannot_steal_keyboard_focus() { + if !crate::startup_tests::private_runtime( + "input::layer_focus_tests::fullscreen_hidden_top_commits_cannot_steal_keyboard_focus", + ) { + return; + } + let (mut state, mut events, output) = fixture(); + let (window, _window_wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + assert!(state.activate_managed_window(id)); + let fallback = Some(window.toplevel().unwrap().wl_surface().clone()); + let keyboard = state.seat.get_keyboard().unwrap(); + state.set_window_fullscreen(id, true); + let (top, mut top_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2); + assert!(state.output_has_fullscreen(&output)); + assert!( + state.layer_under((20., 20.).into()).is_none(), + "Top is excluded from hit testing by fullscreen policy" + ); + assert_eq!( + keyboard.current_focus(), + fallback, + "a hidden Top layer's map commit cannot steal keyboard focus" + ); + commit_policy(&mut state, &mut events, &mut top_wire, 1); + assert_eq!( + keyboard.current_focus(), + fallback, + "hidden animation commits cannot steal focus" + ); + state.set_window_fullscreen(id, false); + assert_eq!( + keyboard.current_focus(), + Some(top.wl_surface().clone()), + "fullscreen exit restores visible exclusive layers" + ); + assert_eq!(state.layer_under((20., 20.).into()).unwrap().0, top); + state.set_window_fullscreen(id, true); + assert_eq!( + keyboard.current_focus(), + fallback, + "fullscreen entry revokes hidden Top focus immediately" + ); + let (overlay, mut overlay_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3); + assert_eq!( + keyboard.current_focus(), + Some(overlay.wl_surface().clone()), + "Overlay remains eligible over fullscreen" + ); + assert_eq!(state.layer_under((20., 20.).into()).unwrap().0, overlay); + request(&mut overlay_wire, 3, 7, &[], None); + dispatch(&mut events, &mut state); + assert_eq!( + keyboard.current_focus(), + fallback, + "destroying Overlay must not restore hidden Top" + ); + request(&mut top_wire, 2, 1, &[0, 0, 0], None); + request(&mut top_wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + request(&mut top_wire, 3, 0, &[65, 48], None); + request(&mut top_wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + ack_latest_configure(&mut top_wire); + request(&mut top_wire, 2, 1, &[5, 0, 0], None); + request(&mut top_wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert_eq!( + keyboard.current_focus(), + fallback, + "hidden remapping cannot reclaim focus" + ); + state.set_window_fullscreen(id, false); + commit_policy(&mut state, &mut events, &mut top_wire, 2); + state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true); + assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone())); + let pointer = state.seat.get_pointer().unwrap(); + pointer.motion( + &mut state, + Some((top.wl_surface().clone(), (0., 0.).into())), + &MotionEvent { + location: (20., 20.).into(), + serial: SERIAL_COUNTER.next_serial(), + time: 0, + }, + ); + state.set_window_fullscreen(id, true); + assert_eq!( + keyboard.current_focus(), + fallback, + "fullscreen also revokes on-demand Top focus" + ); + commit_policy(&mut state, &mut events, &mut top_wire, 0); + commit_policy(&mut state, &mut events, &mut top_wire, 2); + assert_eq!( + keyboard.current_focus(), + fallback, + "a stale pointer owner cannot activate a hidden panel" + ); + state.set_window_fullscreen(id, false); + assert_eq!( + keyboard.current_focus(), + fallback, + "on-demand layers require interaction after fullscreen exit" + ); + state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true); + assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone())); +} + +#[test] +fn valid_layer_popup_grab_survives_commits_but_not_parent_unmapping() { + use smithay::desktop::{PopupKeyboardGrab, PopupKind}; + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase; + use smithay::wayland::shell::xdg::{XdgShellHandler, XdgWmBaseUserData}; + if !crate::startup_tests::private_runtime( + "input::layer_focus_tests::valid_layer_popup_grab_survives_commits_but_not_parent_unmapping", + ) { + return; + } + let (mut state, mut events, output) = fixture(); + let (top, mut wire) = layer_with_policy(&mut state, &mut events, &output, 2, 2); + state.focus_window_at((20., 20.).into(), SERIAL_COUNTER.next_serial(), true); + let client = top.wl_surface().client().unwrap(); + let compositor = client + .create_resource::(&state.display_handle, 6, ()) + .unwrap(); + let shell = client + .create_resource::(&state.display_handle, 6, Default::default()) + .unwrap(); + request(&mut wire, shell.id().protocol_id(), 1, &[6], None); // Positioner. + request(&mut wire, 6, 1, &[32, 24], None); + request(&mut wire, 6, 2, &[0, 0, 32, 24], None); + request(&mut wire, compositor.id().protocol_id(), 0, &[7], None); + request(&mut wire, shell.id().protocol_id(), 2, &[8, 7], None); + request(&mut wire, 8, 2, &[9, 0, 6], None); // Popup with layer parent assigned next. + request(&mut wire, 3, 5, &[9], None); + dispatch(&mut events, &mut state); + let popup = state + .xdg_shell_state() + .popup_surfaces() + .iter() + .find(|popup| { + popup + .wl_surface() + .client() + .is_some_and(|owner| owner.id() == client.id()) + }) + .unwrap() + .clone(); + let serial = SERIAL_COUNTER.next_serial(); + let seat = state.seat.clone(); + let grab = state + .popups + .grab_popup(top.wl_surface().clone(), PopupKind::Xdg(popup.clone()), &seat, serial) + .unwrap(); + let keyboard = state.seat.get_keyboard().unwrap(); + keyboard.set_grab(&mut state, PopupKeyboardGrab::new(&grab), serial); + keyboard.set_focus(&mut state, grab.current_grab(), serial); + let popup_focus = Some(popup.wl_surface().clone()); + assert_eq!(keyboard.current_focus(), popup_focus); + commit_policy(&mut state, &mut events, &mut wire, 2); + assert!(keyboard.is_grabbed()); + assert_eq!( + keyboard.current_focus(), + popup_focus, + "valid popup grabs survive parent commits" + ); + let (_overlay, _overlay_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 3); + assert_eq!( + keyboard.current_focus(), + popup_focus, + "new exclusive layers do not interrupt a valid popup grab" + ); + request(&mut wire, 2, 1, &[0, 0, 0], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!(!keyboard.is_grabbed(), "unmapping revokes the parent popup grab"); + assert_ne!(keyboard.current_focus(), popup_focus); + assert_ne!(keyboard.current_focus(), Some(top.wl_surface().clone())); +} + +#[test] +fn fullscreen_layer_eligibility_is_per_output_and_respects_overview() { + if !crate::startup_tests::private_runtime( + "input::layer_focus_tests::fullscreen_layer_eligibility_is_per_output_and_respects_overview", + ) { + return; + } + let (mut state, mut events, output) = fixture(); + let (window, _window_wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + assert!(state.activate_managed_window(id)); + state.set_window_fullscreen(id, true); + let (top, _top_wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2); + assert!(!state.layer_keyboard_eligible(&top)); + assert!(!state.layer_visible_for_frame(&output, smithay::wayland::shell::wlr_layer::Layer::Top, false)); + assert!(state.layer_visible_for_frame(&output, smithay::wayland::shell::wlr_layer::Layer::Top, true)); + state.set_overview_active(true); + assert!(state.layer_keyboard_eligible(&top)); + assert_eq!( + state.seat.get_keyboard().unwrap().current_focus(), + Some(top.wl_surface().clone()) + ); + state.set_overview_active(false); + assert!(!state.layer_keyboard_eligible(&top)); + assert_eq!( + state.seat.get_keyboard().unwrap().current_focus(), + Some(window.toplevel().unwrap().wl_surface().clone()) + ); + let other = Output::new( + "other".into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + other.change_current_state( + Some(Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&other, (800, 0)); + state.register_output(&other, "other".into()); + // Move the test role into the second output's map to isolate visibility + // policy from the separately tested output-selection policy. + let (other_top, mut wire) = layer_with_policy(&mut state, &mut events, &output, 0, 2); + layer_map_for_output(&output).unmap_layer(&other_top); + layer_map_for_output(&other).map_layer(&other_top).unwrap(); + commit_policy(&mut state, &mut events, &mut wire, 1); + assert!( + state.layer_keyboard_eligible(&other_top), + "fullscreen on the first output does not hide Top on another" + ); + assert_eq!( + state.seat.get_keyboard().unwrap().current_focus(), + Some(other_top.wl_surface().clone()) + ); + state.unregister_output(&other); + assert!(!state.layer_keyboard_eligible(&other_top)); + assert_eq!( + state.seat.get_keyboard().unwrap().current_focus(), + Some(window.toplevel().unwrap().wl_surface().clone()) + ); +} + +#[test] +fn animated_overview_exit_revokes_top_focus_when_it_becomes_hidden() { + if !crate::startup_tests::private_runtime( + "input::layer_focus_tests::animated_overview_exit_revokes_top_focus_when_it_becomes_hidden", + ) { + return; + } + let (mut state, mut events, output) = fixture_with_animations(true); + let (window, _window_wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + assert!(state.activate_managed_window(id)); + state.set_window_fullscreen(id, true); + let (top, _wire) = layer_with_policy(&mut state, &mut events, &output, 1, 2); + let keyboard = state.seat.get_keyboard().unwrap(); + state.set_overview_active(true); + assert!(state.layer_keyboard_eligible(&top)); + assert_eq!(keyboard.current_focus(), Some(top.wl_surface().clone())); + let tick = std::time::Instant::now(); + for step in 1..=300 { + state.advance_animations(tick + std::time::Duration::from_millis(16 * step)); + } + state.set_overview_active(false); + assert!( + state.layer_keyboard_eligible(&top), + "Top remains visible during the overview exit animation" + ); + for step in 1..=300 { + state.advance_animations(tick + std::time::Duration::from_millis(4800 + 16 * step)); + } + assert!(!state.layer_keyboard_eligible(&top)); + assert_eq!( + keyboard.current_focus(), + Some(window.toplevel().unwrap().wl_surface().clone()), + "completion of the visibility transition revokes focus without a client commit" + ); +} diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 14988f3..503807c 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -637,6 +637,7 @@ impl Ferese { self.overview.exit(normal, self.animations_enabled()); } + self.reconcile_layer_keyboard_focus(); self.notify_overview_state(); crate::backends::direct::render_all(self); } diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index 056d6f7..56972fd 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -126,7 +126,7 @@ fn scene_elements( } else { Vec::new() }; - let upper_layers: &[Layer] = if state.output_has_fullscreen_for_frame(output, frame.overview.is_presenting()) { + let upper_layers: &[Layer] = if !state.layer_visible_for_frame(output, Layer::Top, frame.overview.is_presenting()) { &[Layer::Overlay] } else { &[Layer::Overlay, Layer::Top] diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 1d7027f..4f63860 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -519,9 +519,13 @@ impl Ferese { if !self.overview.is_active() { self.retarget_overview(); } + let overview_was_presenting = self.overview.is_presenting(); active_animation |= self .overview .advance(delta, self.spring_config, self.animations_enabled); + if overview_was_presenting != self.overview.is_presenting() { + self.reconcile_layer_keyboard_focus(); + } self.refresh_workspace_slide_offsets(); self.sync_window_stacking(); diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index daea9ef..711709b 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -173,6 +173,14 @@ impl Ferese { .is_some_and(|workspace| workspace.fullscreen.is_some()) } + pub(crate) fn layer_visible_on_output(&self, output: &Output, layer: Layer) -> bool { + layer != Layer::Top || !self.output_has_fullscreen(output) + } + + pub(crate) fn layer_visible_for_frame(&self, output: &Output, layer: Layer, overview: bool) -> bool { + layer != Layer::Top || !self.output_has_fullscreen_for_frame(output, overview) + } + pub fn set_window_fullscreen(&mut self, window: WindowId, enabled: bool) { match self.workspaces.set_fullscreen(window, enabled) { Ok(true) => self.relayout_window(window), diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index 2786754..d5bf4ef 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -104,7 +104,7 @@ impl Ferese { let map = layer_map_for_output(output); for requested in layers { - if *requested == Layer::Top && self.output_has_fullscreen(output) { + if !self.layer_visible_on_output(output, *requested) { continue; } for layer in map.layers_on(*requested).rev() { diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index e04f6fb..db1f7bd 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -641,6 +641,7 @@ impl Ferese { self.sync_window_stacking(); self.retarget_overview(); self.reconcile_tab_indicator(); + self.reconcile_layer_keyboard_focus(); if !finalize { return; }