From 76112b59ac02f9fe4f9b43bf596feb81cfbf8625 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 7 Oct 2026 08:13:42 +0100 Subject: [PATCH] Add MRU focus switching between tiled and floating layers --- compositor/src/config.rs | 19 +- compositor/src/config/bindings.rs | 7 + compositor/src/input.rs | 1 + compositor/src/ipc.rs | 1 + compositor/src/state/focus.rs | 293 ++++++++++++++++++++++++++++++ docs/configuration.md | 8 +- docs/ipc.md | 10 + feresectl/src/cli.rs | 3 + feresectl/tests/cli.rs | 26 +++ 9 files changed, 364 insertions(+), 4 deletions(-) diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 165f801..27f9a76 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -484,11 +484,23 @@ mod tests { assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err()); } + #[test] + fn focus_floating_is_bound_to_super_v_and_accepts_a_custom_binding() { + for source in ["", "binding \"Super+V\" \"focus-floating\"\n"] { + let configured = parse(source).runtime_config().unwrap(); + assert!(configured.bindings.iter().any(|binding| { + binding.action == BindingAction::FocusFloating + && binding.matches(55_u32.into(), &[keysyms::KEY_v], true, false, false, false) + })); + } + } + #[test] fn focus_history_actions_are_bindable_and_have_defaults() { let defaults = Config::default().runtime_config().unwrap(); for action in [ BindingAction::FocusLastWindow, + BindingAction::FocusFloating, BindingAction::FocusMru(false), BindingAction::FocusMru(true), ] { @@ -496,6 +508,7 @@ mod tests { } for (name, action) in [ ("focus-last-window", BindingAction::FocusLastWindow), + ("focus-floating", BindingAction::FocusFloating), ("focus-mru-next", BindingAction::FocusMru(false)), ("focus-mru-previous", BindingAction::FocusMru(true)), ] { @@ -1056,7 +1069,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 58); + assert_eq!(bindings.len(), 59); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -1100,7 +1113,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 58); + assert_eq!(bindings.len(), 59); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) @@ -1108,7 +1121,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 57); + assert_eq!(bindings.len(), 58); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 812b12c..c237bdf 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -276,6 +276,10 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::FocusLastWindow) } + "focus-floating" => { + no_argument()?; + Ok(BindingAction::FocusFloating) + } "focus-mru-next" | "focus-mru-previous" => { no_argument()?; Ok(BindingAction::FocusMru(action == "focus-mru-previous")) @@ -391,6 +395,7 @@ pub(super) fn default_bindings() -> Vec { binding("Super+[", "consume", None), binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), + binding("Super+V", "focus-floating", None), binding("Super+Tab", "toggle-overview", None), binding("XF86Display", "toggle-display-mode", None), binding("XF86AudioPlay", "media-play-pause", None), @@ -467,6 +472,7 @@ pub enum BindingAction { Exit, Focus(ferese_layout::Direction), FocusLastWindow, + FocusFloating, FocusMru(bool), Move(ferese_layout::Direction), Resize(ferese_layout::Direction), @@ -572,6 +578,7 @@ impl Binding { BindingAction::Exit => "Log out (with confirmation)".into(), BindingAction::Focus(direction) => format!("Focus window {direction:?}"), BindingAction::FocusLastWindow => "Return to last focused window".into(), + BindingAction::FocusFloating => "Switch focus between tiled and floating windows".into(), BindingAction::FocusMru(reverse) => if *reverse { "Previous window in focus history" } else { diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 8112f56..b0b3f3c 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1094,6 +1094,7 @@ impl Ferese { BindingAction::Exit => self.request_logout_confirmation(), BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::FocusLastWindow => self.focus_last_window(), + BindingAction::FocusFloating => self.focus_floating(), BindingAction::FocusMru(reverse) => self.cycle_focus(reverse, false), BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 84ff48d..7175b4a 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -855,6 +855,7 @@ impl Ferese { .map_err(|e| CommandError::new("invalid_config", e))?, "focus" => self.focus_direction(direction_arg(args)?), "focus-last-window" => self.focus_last_window(), + "focus-floating" => self.focus_floating(), "focus-mru-next" => self.cycle_focus(false, false), "focus-mru-previous" => self.cycle_focus(true, false), "move" => self.move_direction(direction_arg(args)?), diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs index c5a42a9..730b64c 100644 --- a/compositor/src/state/focus.rs +++ b/compositor/src/state/focus.rs @@ -38,6 +38,35 @@ impl Ferese { } } + pub(crate) fn focus_floating(&mut self) { + if self.session_lock.active() || self.input_capture.captures(1) { + return; + } + let Some(current) = self.focused_window else { + return; + }; + let workspace = self.workspaces.active_id(); + if self.workspaces.workspace_for_window(current) != Some(workspace) { + return; + } + let target = self + .focus_history + .candidates(self.focused_window, self.focus_candidates()) + .into_iter() + .find(|id| { + self.workspaces.workspace_for_window(*id) == Some(workspace) + && matches!( + (self.workspaces.placement(current), self.workspaces.placement(*id)), + (Some(WindowPlacement::Tiled), Some(WindowPlacement::Floating { .. })) + | (Some(WindowPlacement::Floating { .. }), Some(WindowPlacement::Tiled)) + ) + }); + if let Some(id) = target { + self.cancel_focus_cycle(); + self.activate_managed_window(id); + } + } + pub(crate) fn cycle_focus(&mut self, reverse: bool, preview: bool) { if self.session_lock.active() || self.input_capture.captures(1) { return; @@ -96,3 +125,267 @@ impl Ferese { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::capture_privacy::tests::window; + use smithay::reexports::calloop::EventLoop; + use std::os::unix::net::UnixStream; + + struct Fixture { + events: EventLoop<'static, Ferese>, + state: Ferese, + ids: Vec, + _clients: Vec, + } + + fn fixture(test: &str, floating: &[bool]) -> Option { + if !crate::startup_tests::private_runtime(&format!("state::focus::tests::{test}")) { + return None; + } + let mut events = EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + state.animations_enabled = false; + let output = Output::new( + "focus".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "focus".into()); + let mut ids = Vec::new(); + let mut clients = Vec::new(); + for floating in floating { + let (window, wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + assert!(state.activate_managed_window(id)); + if *floating { + state.toggle_focused_floating(); + } + ids.push(id); + clients.push(wire); + } + Some(Fixture { + events, + state, + ids, + _clients: clients, + }) + } + + impl Fixture { + fn focus(&mut self, index: usize) { + assert!(self.state.activate_managed_window(self.ids[index])); + } + + fn placements(&self) -> Vec> { + self.ids.iter().map(|id| self.state.workspaces.placement(*id)).collect() + } + + fn press(&mut self, expected: usize) { + let workspace = self.state.workspaces.active_id(); + let placements = self.placements(); + self.state.focus_floating(); + self.assert_focus(expected); + assert_eq!(self.state.workspaces.active_id(), workspace); + assert_eq!(self.placements(), placements); + } + + fn assert_focus(&self, expected: usize) { + let id = self.ids[expected]; + assert_eq!(self.state.focused_window, Some(id)); + assert_eq!(self.state.workspaces.active().last_focused, Some(id)); + assert_eq!( + self.state.seat.get_keyboard().unwrap().current_focus(), + Some( + self.state + .windows + .window(id) + .unwrap() + .toplevel() + .unwrap() + .wl_surface() + .clone() + ) + ); + } + } + + #[test] + fn tiled_focuses_most_recently_focused_float() { + let Some(mut f) = fixture("tiled_focuses_most_recently_focused_float", &[false, true, true]) else { + return; + }; + f.focus(1); + f.focus(0); + f.press(1); + } + + #[test] + fn floating_focuses_most_recently_focused_tile() { + let Some(mut f) = fixture("floating_focuses_most_recently_focused_tile", &[false, false, true]) else { + return; + }; + f.focus(0); + f.focus(2); + f.press(0); + } + + #[test] + fn multiple_floaters_do_not_cycle_or_wrap() { + let Some(mut f) = fixture("multiple_floaters_do_not_cycle_or_wrap", &[false, true, true, true]) else { + return; + }; + f.focus(2); + f.focus(0); + for _ in 0..3 { + f.press(2); + f.press(0); + } + } + + #[test] + fn no_floating_candidate_is_a_no_op() { + let Some(mut f) = fixture("no_floating_candidate_is_a_no_op", &[false, false]) else { + return; + }; + f.focus(0); + let history = f.state.focus_history.candidates(None, &f.ids); + f.press(0); + f.press(0); + assert_eq!(f.state.focus_history.candidates(None, &f.ids), history); + } + + #[test] + fn no_tiled_candidate_is_a_no_op() { + let Some(mut f) = fixture("no_tiled_candidate_is_a_no_op", &[true, true]) else { + return; + }; + f.focus(0); + let history = f.state.focus_history.candidates(None, &f.ids); + f.press(0); + f.press(0); + assert_eq!(f.state.focus_history.candidates(None, &f.ids), history); + } + + #[test] + fn candidates_on_other_workspaces_are_ignored_in_both_directions() { + let Some(mut f) = fixture( + "candidates_on_other_workspaces_are_ignored_in_both_directions", + &[false, true, false, true], + ) else { + return; + }; + for index in [2, 3] { + f.focus(index); + f.state.move_focused_to_workspace(2); + } + f.focus(1); + f.focus(3); + f.focus(0); + f.press(1); + f.focus(2); + f.focus(1); + f.press(0); + f.focus(0); + f.state.move_focused_to_workspace(2); + f.focus(1); + f.press(1); + f.focus(3); + f.state.move_focused_to_workspace(1); + f.focus(2); + f.press(2); + } + + #[test] + fn repeated_presses_follow_updated_focus_history_between_layers() { + let Some(mut f) = fixture( + "repeated_presses_follow_updated_focus_history_between_layers", + &[false, false, true, true], + ) else { + return; + }; + f.focus(0); + f.focus(2); + for _ in 0..3 { + f.press(0); + f.press(2); + } + f.focus(1); + f.press(2); + f.press(1); + f.focus(3); + f.press(1); + f.press(3); + } + + #[test] + fn focus_floating_preserves_all_window_placements() { + let Some(mut f) = fixture("focus_floating_preserves_all_window_placements", &[false, true, true]) else { + return; + }; + let placements = f.placements(); + f.focus(0); + f.press(2); + f.press(0); + assert_eq!(f.placements(), placements); + } + + #[test] + fn ipc_focus_floating_uses_the_managed_activation_path() { + use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + let Some(mut f) = fixture("ipc_focus_floating_uses_the_managed_activation_path", &[false, true]) else { + return; + }; + f.focus(0); + let workspace = f.state.workspaces.active_id(); + let placements = f.placements(); + let path = f.state.session_environment.control_socket.clone(); + let (send, receive) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut socket = UnixStream::connect(path).unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + write_frame( + &mut socket, + &Request { + version: VERSION, + id: 1, + kind: "command".into(), + command: "focus-floating".into(), + args: serde_json::json!({}), + }, + ) + .unwrap(); + send.send(read_frame::(&mut socket).unwrap()).unwrap(); + }); + let deadline = Instant::now() + Duration::from_secs(3); + let response = loop { + if let Ok(response) = receive.try_recv() { + break response; + } + assert!(Instant::now() < deadline, "IPC focus action timed out"); + f.events.dispatch(Duration::from_millis(1), &mut f.state).unwrap(); + }; + worker.join().unwrap(); + assert!(response.error.is_none(), "{:?}", response.error); + f.assert_focus(1); + assert_eq!(f.state.workspaces.active_id(), workspace); + assert_eq!(f.placements(), placements); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 761b71f..4fad550 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -518,6 +518,7 @@ invalid. A disabled binding must omit `action` and `argument`. | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `workspace-back-and-forth` | None; return to the previous workspace on this monitor | | `focus-last-window` | None; return to the last focused window | +| `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace | | `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order | | `none` | None; ignore this trigger | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | @@ -527,13 +528,18 @@ commands { terminal "foot" } binding keys="Super+Enter" action="spawn" argument="terminal" +binding keys="Super+V" action="focus-floating" ``` +`focus-floating` switches focus between tiled and floating windows without +changing placement. It stays on the active workspace and does nothing when the +opposite layer has no candidate. `toggle-floating` changes window placement. + Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; -Super+M layout; Super+Shift+Space floating; Super+Tab overview; +Super+M layout; Super+Shift+Space floating; Super+V focus tiled/floating; Super+Tab overview; Super+Escape previous visited workspace; Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching; Super+Shift+S area screenshot; Print Screen whole-screen screenshot; diff --git a/docs/ipc.md b/docs/ipc.md index 8908495..0ab0e30 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -35,6 +35,16 @@ feresectl screenshot -g '0,0 800x600' > region.png feresectl screenshot-window 7 > window.png ``` +## Window focus + +```sh +feresectl focus-floating +``` + +`focus-floating` focuses the most recently focused window in the opposite +tiled/floating layer on the active workspace. It does nothing when that layer +has no candidate. `toggle-floating` changes placement; `focus-floating` changes focus. + ## Choose a session Every feresectl IPC command, including theme commands and subscriptions, resolves diff --git a/feresectl/src/cli.rs b/feresectl/src/cli.rs index 8a46894..a483441 100644 --- a/feresectl/src/cli.rs +++ b/feresectl/src/cli.rs @@ -80,6 +80,8 @@ pub enum Command { WorkspaceBackAndForth, /// Focus the previously focused window. FocusLastWindow, + /// Focus the most recently focused window in the opposite tiled/floating layer on this workspace. + FocusFloating, /// Focus the next recently used window. FocusMruNext, /// Focus the previous recently used window. @@ -239,6 +241,7 @@ impl Command { Self::RequestLogout => ("request-logout", json!({})), Self::WorkspaceBackAndForth => ("workspace-back-and-forth", json!({})), Self::FocusLastWindow => ("focus-last-window", json!({})), + Self::FocusFloating => ("focus-floating", json!({})), Self::FocusMruNext => ("focus-mru-next", json!({})), Self::FocusMruPrevious => ("focus-mru-previous", json!({})), Self::Focus { direction } => ("focus", json!({"direction": value_name(direction)})), diff --git a/feresectl/tests/cli.rs b/feresectl/tests/cli.rs index 1a5b308..edabd86 100644 --- a/feresectl/tests/cli.rs +++ b/feresectl/tests/cli.rs @@ -41,6 +41,7 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() { &["media", "--help"], &["xwayland", "--help"], &["screenshot", "--help"], + &["focus-floating", "--help"], ] { let text = success(ctl(args).output().unwrap()); assert!(text.contains("Usage:")); @@ -52,6 +53,31 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() { assert!(error.contains("windows"), "{error}"); } +#[test] +fn focus_floating_sends_a_parameterless_action_and_rejects_cycle_arguments() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("control.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let request: Request = read_frame(&mut stream).unwrap(); + assert_eq!(request.command, "focus-floating"); + assert_eq!(request.args, json!({})); + write_frame(&mut stream, &Response::success(request.id, json!({}))).unwrap(); + }); + success( + ctl(&["--socket", socket.to_str().unwrap(), "focus-floating"]) + .output() + .unwrap(), + ); + worker.join().unwrap(); + let output = ctl(&["focus-floating", "next"]).output().unwrap(); + assert_eq!(output.status.code(), Some(2)); +} + #[test] fn explicit_socket_targets_the_nested_instance_for_queries_and_theme_commands() { let directory = tempfile::tempdir().unwrap();