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 <[email protected]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-14 13:30:14 +01:00
1 parent b5f7793870
commit 9251522a69
8 files changed
+312 -4

No files matched your search

+2 -1
View File
@@ -205,7 +205,8 @@ impl Ferese {
})
.unwrap_or(false)
&& self.space.outputs().any(|output| {
layer_map_for_output(output)
self.layer_visible_on_output(output, layer.layer())
&& layer_map_for_output(output)
.layers()
.any(|candidate| candidate == layer)
})
+294 -1
View File
@@ -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::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let shell = client
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&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"
);
}
+1
View File
@@ -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);
}
+1 -1
View File
@@ -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]
+4
View File
@@ -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();
+8
View File
@@ -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),
+1 -1
View File
@@ -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() {
+1
View File
@@ -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;
}