From 0162870ccae01e864bdd4432286ec04614751258 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 16:00:23 +0100 Subject: [PATCH] Add focus history and Alt-Tab window switching --- README.md | 3 + compositor/src/config.rs | 43 +++++- compositor/src/config/bindings.rs | 20 +++ compositor/src/handlers/mod.rs | 10 ++ compositor/src/handlers/xdg_shell.rs | 6 +- compositor/src/input.rs | 34 ++++- compositor/src/ipc.rs | 3 + compositor/src/main.rs | 3 + compositor/src/overview.rs | 58 ++++++++ compositor/src/state.rs | 5 + compositor/src/state/focus.rs | 100 +++++++++++++ compositor/src/state/hit_testing.rs | 3 + compositor/src/state/windows.rs | 1 + crates/ferese-core/src/focus.rs | 144 +++++++++++++++++++ crates/ferese-core/src/lib.rs | 2 + docs/configuration.md | 19 +++ feresectl/src/main.rs | 11 +- scripts/tests/test_focus_history_isolated.py | 114 +++++++++++++++ 18 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 compositor/src/state/focus.rs create mode 100644 crates/ferese-core/src/focus.rs create mode 100644 scripts/tests/test_focus_history_isolated.py diff --git a/README.md b/README.md index 85121ab..6fa78eb 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ Shortcuts** whenever you want to change a shortcut or gesture. | --- | --- | | Super + Enter | Open a terminal | | Super + H / J / K / L | Focus left / down / up / right | +| Super + Backspace | Return to the last focused window | +| Super + Escape | Toggle between the last two visited workspaces on this monitor | +| Alt + Tab / Alt + Shift + Tab | Preview recent windows forward / backward; release Alt to select, Escape to cancel | | Super + F | Maximize the window | | Super + Shift + F | Toggle fullscreen | | Super + R | Cycle column width | diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 34a40c1..0102c60 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -438,6 +438,43 @@ mod tests { assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err()); } + #[test] + fn focus_history_actions_are_bindable_and_have_defaults() { + let defaults = Config::default().runtime_config().unwrap(); + for action in [ + BindingAction::FocusLastWindow, + BindingAction::FocusMru(false), + BindingAction::FocusMru(true), + ] { + assert!(defaults.bindings.iter().any(|binding| binding.action == action)); + } + for (name, action) in [ + ("focus-last-window", BindingAction::FocusLastWindow), + ("focus-mru-next", BindingAction::FocusMru(false)), + ("focus-mru-previous", BindingAction::FocusMru(true)), + ] { + assert_eq!(parse_action(name, None, &HashMap::new()).unwrap(), action); + assert!(parse_action(name, Some("left"), &HashMap::new()).is_err()); + } + let bindings = &defaults.bindings; + assert!(bindings.iter().any(|binding| binding.matches( + 23_u32.into(), + &[keysyms::KEY_Tab], + false, + false, + true, + false + ) && binding.action == BindingAction::FocusMru(false))); + assert!(bindings.iter().any(|binding| binding.matches( + 23_u32.into(), + &[keysyms::KEY_Tab], + false, + false, + true, + true + ) && binding.action == BindingAction::FocusMru(true))); + } + #[test] fn packaged_and_custom_kdl_pass_runtime_validation() { Config::parse_source(include_str!("../../packaging/config.kdl")) @@ -888,7 +925,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 50); + assert_eq!(bindings.len(), 53); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -931,14 +968,14 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 50); + assert_eq!(bindings.len(), 53); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()]) })); let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 49); + assert_eq!(bindings.len(), 52); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 0a39252..37727e8 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -267,6 +267,14 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::WorkspaceBackAndForth) } + "focus-last-window" => { + no_argument()?; + Ok(BindingAction::FocusLastWindow) + } + "focus-mru-next" | "focus-mru-previous" => { + no_argument()?; + Ok(BindingAction::FocusMru(action == "focus-mru-previous")) + } "spawn" => { let command = required_argument()?; let argv = commands @@ -369,6 +377,9 @@ pub(super) fn default_bindings() -> Vec { binding("Super+Tab", "toggle-overview", None), binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+Escape", "workspace-back-and-forth", None), + binding("Super+BackSpace", "focus-last-window", None), + binding("Alt+Tab", "focus-mru-next", None), + binding("Alt+Shift+Tab", "focus-mru-previous", None), binding("Super+Shift+E", "exit", None), ]; @@ -433,6 +444,8 @@ pub enum BindingAction { Close, Exit, Focus(ferese_layout::Direction), + FocusLastWindow, + FocusMru(bool), Move(ferese_layout::Direction), Resize(ferese_layout::Direction), SwitchWorkspace(u8), @@ -534,6 +547,13 @@ impl Binding { BindingAction::Close => "Close window".into(), BindingAction::Exit => "Log out (with confirmation)".into(), BindingAction::Focus(direction) => format!("Focus window {direction:?}"), + BindingAction::FocusLastWindow => "Return to last focused window".into(), + BindingAction::FocusMru(reverse) => if *reverse { + "Previous window in focus history" + } else { + "Next window in focus history" + } + .into(), BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index dc40262..336ab40 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -59,6 +59,16 @@ impl SeatHandler for Ferese { } fn focus_changed(&mut self, seat: &Seat, focused: Option<&WlSurface>) { + if let Some(id) = focused.and_then(|surface| { + self.window_ids.iter().find_map(|(window, id)| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface) + .then_some(*id) + }) + }) { + self.focus_history.record(id); + } if let Some(inhibitor) = self.active_shortcuts_inhibitor.take() && inhibitor.is_active() { diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 66d31ee..38117fd 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -58,9 +58,11 @@ impl XdgShellHandler for Ferese { } fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + // Hidden workspace windows are managed but unmapped from Space. let Some(window) = self - .space - .elements() + .window_ids + .keys() + .chain(self.space.elements()) .find(|window| { window .toplevel() diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 12baf53..1992074 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -275,6 +275,13 @@ impl Ferese { SERIAL_COUNTER.next_serial(), event.time_msec(), |data, modifiers, keysym| { + if data.focus_cycle.is_some() { + if data.session_lock.active || data.input_capture.captures(1) { + data.cancel_focus_cycle(); + } else if !modifiers.alt { + data.finish_focus_cycle(); + } + } if state == KeyState::Released { data.portal_shortcuts.release(keycode, Event::time(&event)); if data.intercepted_keys.remove(&keycode) { @@ -285,6 +292,13 @@ impl Ferese { return FilterResult::Forward; } let symbol = keysym.modified_sym().raw(); + if data.focus_cycle.is_some() && symbol == keysyms::KEY_Escape { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.cancel_focus_cycle(); + } + return FilterResult::Intercept(()); + } if data.input_capture.active() && emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) { if state == KeyState::Pressed { data.input_capture.disable_all(); @@ -362,6 +376,18 @@ impl Ferese { ) .then(|| binding.action.clone()) }); + if data.focus_cycle.is_some() + && state == KeyState::Pressed + && !matches!(action, Some(BindingAction::FocusMru(_))) + && !matches!(symbol, + keysyms::KEY_Alt_L | keysyms::KEY_Alt_R + | keysyms::KEY_Shift_L | keysyms::KEY_Shift_R + | keysyms::KEY_Control_L | keysyms::KEY_Control_R + | keysyms::KEY_Super_L | keysyms::KEY_Super_R) + { + data.intercepted_keys.insert(keycode); + return FilterResult::Intercept(()); + } let Some(action) = action else { if state == KeyState::Pressed && data.portal_shortcuts.press( @@ -379,7 +405,11 @@ impl Ferese { if state == KeyState::Pressed { data.intercepted_keys.insert(keycode); - data.execute_binding(action); + if let BindingAction::FocusMru(reverse) = action { + data.cycle_focus(reverse, modifiers.alt); + } else { + data.execute_binding(action); + } } FilterResult::Intercept(()) @@ -998,6 +1028,8 @@ impl Ferese { BindingAction::Close => self.close_focused_window(), BindingAction::Exit => self.request_logout_confirmation(), BindingAction::Focus(direction) => self.focus_direction(direction), + BindingAction::FocusLastWindow => self.focus_last_window(), + BindingAction::FocusMru(reverse) => self.cycle_focus(reverse, false), BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None), diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index a7986d5..9a3f81f 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -647,6 +647,9 @@ impl Ferese { .reload_config() .map_err(|e| CommandError::new("invalid_config", e))?, "focus" => self.focus_direction(direction_arg(args)?), + "focus-last-window" => self.focus_last_window(), + "focus-mru-next" => self.cycle_focus(false, false), + "focus-mru-previous" => self.cycle_focus(true, false), "move" => self.move_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?), "workspace" => self.switch_workspace(workspace_arg(args)?), diff --git a/compositor/src/main.rs b/compositor/src/main.rs index c49909f..5c322fe 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -116,6 +116,9 @@ fn main() -> Result<(), Box> { state.backdrop_generation = state.backdrop_generation.wrapping_add(1); } // All input/Wayland callbacks have returned, releasing seat locks. + if state.focus_cycle.is_some() && (state.session_lock.active || state.input_capture.captures(1)) { + state.cancel_focus_cycle(); + } // Coalesce cursor changes and redraw here, never inside cursor_image. if state.input_capture.restore_focus { state.restore_input_capture_focus(); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index d0dead0..c0ef305 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -265,6 +265,19 @@ impl OverviewState { self.selected } + pub(crate) fn has_window_preview(&self, id: WindowId) -> bool { + self.presentations.contains_key(&id) + } + + pub(crate) fn select_window(&mut self, id: WindowId) -> bool { + if !self.presentations.contains_key(&id) { + return false; + } + self.selection_workspace = None; + self.selected = Some(id); + true + } + pub(crate) fn select_direction(&mut self, direction: Direction) -> bool { let Some(current) = self .selected @@ -489,6 +502,9 @@ impl Ferese { } pub(crate) fn set_overview_active(&mut self, active: bool) { + if !active { + self.focus_cycle = None; + } if self.overview.is_active() == active { return; } @@ -547,6 +563,13 @@ impl Ferese { let targets = self.overview_targets(); self.overview.retarget(targets, self.animations_enabled()); + if let Some(cycle) = self.focus_cycle.as_mut() { + let available = self.overview.presentations.keys().copied().collect::>(); + if let Some(id) = cycle.reconcile(&available) { + self.overview.select_window(id); + } + return; + } if self.overview.selection_workspace.is_some() { let id = self.workspaces.active_id(); self.overview.selection_workspace = Some(id); @@ -752,6 +775,22 @@ impl Ferese { let Some(bounds) = self.output_bounds_for(output) else { continue; }; + if let Some(cycle) = &self.focus_cycle { + let windows = cycle + .windows() + .iter() + .filter_map(|id| { + if self.focus_preview_output(*id) != Some(output_id) { + return None; + } + Some((*id, self.window_geometry.get(id)?.visual.current)) + }) + .collect::>(); + for (id, target) in overview_layout(window_area(bounds), &windows) { + targets.insert(id, (self.window_geometry[&id].visual.current, target)); + } + continue; + } let Some(workspace) = self.workspaces.workspace(workspace) else { continue; }; @@ -1053,6 +1092,25 @@ mod tests { assert!((preview.width / preview.height - source.width / source.height).abs() < 0.001); } + #[test] + fn mru_selection_can_cross_workspace_selection_and_rejects_missing_previews() { + let first = WindowId(1); + let second = WindowId(2); + let normal = Rect::new(0.0, 0.0, 800.0, 600.0); + let mut overview = OverviewState::default(); + overview.enter( + HashMap::from([(first, (normal, normal)), (second, (normal, normal))]), + Some(first), + false, + ); + overview.selection_workspace = Some(WorkspaceId(1)); + assert!(overview.select_window(second)); + assert_eq!(overview.selected(), Some(second)); + assert_eq!(overview.selection_workspace, None); + assert!(!overview.select_window(WindowId(99))); + assert_eq!(overview.selected(), Some(second)); + } + #[test] fn exiting_overview_tracks_a_moving_layout_and_finishes_in_180ms() { let id = WindowId(1); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index a9fed5d..95a3cc3 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1,5 +1,6 @@ mod animation; mod floating; +mod focus; mod hit_testing; mod layout; mod navigation; @@ -363,6 +364,8 @@ pub struct Ferese { viewport_coupled_widths: HashMap, pending_column_width_cycles: HashSet, pub focused_window: Option, + pub(crate) focus_history: ferese_core::FocusHistory, + pub(crate) focus_cycle: Option, column_width_presets: Vec, gap_config: GapConfig, pub(crate) input_settings: InputSettings, @@ -595,6 +598,8 @@ impl Ferese { viewport_coupled_widths: HashMap::new(), pending_column_width_cycles: HashSet::new(), focused_window: None, + focus_history: Default::default(), + focus_cycle: None, column_width_presets: config.column_width_presets, gap_config: config.gap_config, input_settings: config.input_settings, diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs new file mode 100644 index 0000000..ad7aef1 --- /dev/null +++ b/compositor/src/state/focus.rs @@ -0,0 +1,100 @@ +use super::*; + +impl Ferese { + pub(super) fn focus_candidates(&self) -> Vec { + self.window_ids + .iter() + .filter_map(|(window, id)| { + self.workspaces.workspace_for_window(*id)?; + (window.toplevel().is_some() + && self.window_content_ready(window) + && self.window_geometry.contains_key(id) + && self.output_workspaces.focused_output().is_some() + && !self.closing_windows.contains_key(id)) + .then_some(*id) + }) + .collect() + } + + pub(crate) fn focus_preview_output(&self, id: WindowId) -> Option { + if self.closing_windows.contains_key(&id) { + return None; + } + self.workspaces + .workspace_for_window(id) + .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)) + .or_else(|| self.output_workspaces.focused_output()) + } + + pub(crate) fn focus_last_window(&mut self) { + if self.session_lock.active || self.input_capture.captures(1) { + return; + } + self.cancel_focus_cycle(); + let target = self + .focus_history + .candidates(self.focused_window, self.focus_candidates()) + .into_iter() + .find(|id| Some(*id) != self.focused_window); + if let Some(id) = target { + 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; + } + let available = self.focus_candidates(); + if !preview { + self.cancel_focus_cycle(); + let order = self.focus_history.candidates(self.focused_window, available.clone()); + let mut cycle = ferese_core::FocusCycle::new(order, self.focused_window); + if let Some(id) = cycle.advance(reverse, &available) { + self.activate_managed_window(id); + } + return; + } + if self.focus_cycle.is_none() { + let order = self.focus_history.candidates(self.focused_window, available.clone()); + if order.len() < 2 { + return; + } + self.focus_cycle = Some(ferese_core::FocusCycle::new(order, self.focused_window)); + if self.overview.is_active() { + self.retarget_overview(); + } else { + self.set_overview_active(true); + } + } + if let Some(id) = self + .focus_cycle + .as_mut() + .and_then(|cycle| cycle.advance(reverse, &available)) + { + self.overview.select_window(id); + crate::backends::direct::render_all(self); + } else { + self.cancel_focus_cycle(); + } + } + + pub(crate) fn finish_focus_cycle(&mut self) { + let Some(mut cycle) = self.focus_cycle.take() else { + return; + }; + if !self.session_lock.active + && !self.input_capture.captures(1) + && let Some(id) = cycle.reconcile(&self.focus_candidates()) + { + self.activate_managed_window(id); + } + self.set_overview_active(false); + } + + pub(crate) fn cancel_focus_cycle(&mut self) { + if self.focus_cycle.take().is_some() { + self.set_overview_active(false); + } + } +} diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index b19e116..594cfdf 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -184,6 +184,9 @@ impl Ferese { let Some(output_id) = self.output_id(output) else { return false; }; + if self.focus_cycle.is_some() && self.overview.is_active() { + return self.overview.has_window_preview(window) && self.focus_preview_output(window) == Some(output_id); + } let workspace = self.workspaces.workspace_for_window(window); workspace == self.output_workspaces.active_workspace(output_id) || self diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index e0795d6..c4f6f52 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -388,6 +388,7 @@ impl Ferese { let Some(id) = self.window_ids.remove(window) else { return; }; + self.focus_history.remove(id); self.window_geometry.remove(&id); self.resize_transactions.remove(&id); self.resize_snapshots.remove(&id); diff --git a/crates/ferese-core/src/focus.rs b/crates/ferese-core/src/focus.rs new file mode 100644 index 0000000..cdf1c35 --- /dev/null +++ b/crates/ferese-core/src/focus.rs @@ -0,0 +1,144 @@ +use ferese_layout::WindowId; + +/// Most recent focus first. Layer surfaces and temporary selections do not enter this list. +#[derive(Debug, Default)] +pub struct FocusHistory { + windows: Vec, +} + +impl FocusHistory { + pub fn record(&mut self, window: WindowId) { + if self.windows.first() == Some(&window) { + return; + } + self.remove(window); + self.windows.insert(0, window); + } + + pub fn remove(&mut self, window: WindowId) { + self.windows.retain(|id| *id != window); + } + + pub fn candidates(&self, current: Option, mut available: Vec) -> Vec { + available.sort_unstable_by_key(|id| id.0); + available.dedup(); + let mut ordered = Vec::with_capacity(available.len()); + for id in current + .into_iter() + .chain(self.windows.iter().copied()) + .chain(available.iter().copied()) + { + if available.contains(&id) && !ordered.contains(&id) { + ordered.push(id); + } + } + ordered + } +} + +/// A frozen traversal order: previewing does not reorder focus history. +#[derive(Debug)] +pub struct FocusCycle { + windows: Vec, + selected: Option, +} + +impl FocusCycle { + pub fn new(windows: Vec, current: Option) -> Self { + let selected = current.filter(|id| windows.contains(id)); + Self { windows, selected } + } + + pub fn windows(&self) -> &[WindowId] { + &self.windows + } + + pub fn reconcile(&mut self, available: &[WindowId]) -> Option { + if self.selected.is_some_and(|id| available.contains(&id)) { + self.selected + } else { + self.advance(false, available) + } + } + + pub fn advance(&mut self, reverse: bool, available: &[WindowId]) -> Option { + // Keep the old index when the selected window disappears so forward + // traversal selects its successor rather than skipping over it. + let old = self + .selected + .and_then(|id| self.windows.iter().position(|candidate| *candidate == id)); + let before = old.map(|index| self.windows[..index].iter().filter(|id| available.contains(id)).count()); + let survives = self.selected.is_some_and(|id| available.contains(&id)); + self.windows.retain(|id| available.contains(id)); + if self.windows.is_empty() { + self.selected = None; + return None; + } + let len = self.windows.len(); + let index = match (before, reverse, survives) { + (Some(index), true, _) => (index + len - 1) % len, + (Some(index), false, true) => (index + 1) % len, + (Some(index), false, false) => index % len, + (None, true, _) => len - 1, + (None, false, _) => 0, + }; + self.selected = Some(self.windows[index]); + self.selected + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_is_unique_filters_unavailable_and_last_focus_toggles() { + let mut history = FocusHistory::default(); + for id in [1, 2, 3, 3] { + history.record(WindowId(id)); + } + let available = vec![WindowId(1), WindowId(2), WindowId(3), WindowId(4)]; + assert_eq!( + history.candidates(Some(WindowId(3)), available.clone()), + [3, 2, 1, 4].map(WindowId) + ); + history.record(WindowId(2)); + assert_eq!(history.candidates(Some(WindowId(2)), available)[1], WindowId(3)); + history.remove(WindowId(3)); + assert_eq!( + history.candidates(None, vec![WindowId(1), WindowId(4)]), + [1, 4].map(WindowId) + ); + } + + #[test] + fn cycle_freezes_order_wraps_reverses_and_skips_closed_windows() { + let available = [3, 2, 1].map(WindowId); + let mut cycle = FocusCycle::new(available.to_vec(), Some(WindowId(3))); + assert_eq!(cycle.advance(false, &available), Some(WindowId(2))); + assert_eq!(cycle.advance(false, &available), Some(WindowId(1))); + assert_eq!(cycle.advance(false, &available), Some(WindowId(3))); + assert_eq!(cycle.advance(true, &available), Some(WindowId(1))); + assert_eq!(cycle.advance(false, &[WindowId(3), WindowId(2)]), Some(WindowId(3))); + assert_eq!(cycle.advance(false, &[]), None); + } + + #[test] + fn missing_current_focus_starts_at_most_recent_and_new_windows_do_not_join_cycle() { + let mut cycle = FocusCycle::new(vec![WindowId(2), WindowId(1)], None); + assert_eq!( + cycle.advance(false, &[WindowId(3), WindowId(2), WindowId(1)]), + Some(WindowId(2)) + ); + assert_eq!(cycle.advance(true, &[WindowId(2)]), Some(WindowId(2))); + } + + #[test] + fn closing_the_preview_selects_its_successor_before_commit() { + let available = [3, 2, 1].map(WindowId); + let mut cycle = FocusCycle::new(available.to_vec(), Some(WindowId(3))); + assert_eq!(cycle.advance(false, &available), Some(WindowId(2))); + assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1))); + assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1))); + } +} diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 26cd524..426ab22 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -1,8 +1,10 @@ pub mod desktop; +mod focus; pub mod notifications; mod output; mod workspace; +pub use focus::{FocusCycle, FocusHistory}; pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use workspace::{ LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, diff --git a/docs/configuration.md b/docs/configuration.md index 3b6dcd6..c2ec7c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -250,6 +250,22 @@ commands and overview selection still select the requested workspace directly. Use `feresectl workspace-back-and-forth` for an explicit toggle, or bind `workspace-back-and-forth` to another key or gesture. The action takes no argument. +`Super+BackSpace` focuses the last focused window, including windows on other +workspaces or monitors. Repeating it toggles between the two most recently +focused windows. Layer surfaces such as launchers do not replace this history. + +`Alt+Tab` previews windows in focus order using overview. Keep Alt held and press +Tab to advance, or Shift+Tab to go backward. Release Alt to focus the selection; +Escape cancels without changing focus. The order stays fixed until selection is +committed. Closed windows are skipped and newly opened windows join the next +cycle. Preview transitions follow the configured animation speed and reduced +motion settings. + +Bind `focus-last-window`, `focus-mru-next`, or `focus-mru-previous` to customize +these shortcuts. MRU bindings with Alt held preview until Alt is released; +bindings without Alt, gestures, and the corresponding `feresectl` commands +switch immediately. All three actions take no arguments. + ## Input | `input` key | Type | Default | Meaning | @@ -331,6 +347,8 @@ invalid. A disabled binding must omit `action` and `argument`. | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `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-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 | @@ -347,6 +365,7 @@ 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+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; Super+Shift+E logout confirmation. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 67c15a7..118ffed 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -159,7 +159,10 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), | "reload-config" | "exit" | "request-logout" - | "workspace-back-and-forth" => { + | "workspace-back-and-forth" + | "focus-last-window" + | "focus-mru-next" + | "focus-mru-previous" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -188,7 +191,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] @@ -231,6 +234,10 @@ mod tests { ("workspace-back-and-forth".into(), json!({})) ); assert!(parse_args(["workspace-back-and-forth".into(), "2".into()]).is_err()); + for command in ["focus-last-window", "focus-mru-next", "focus-mru-previous"] { + assert_eq!(parse_args([command.into()]).unwrap(), (command.into(), json!({}))); + assert!(parse_args([command.into(), "2".into()]).is_err()); + } } #[test] diff --git a/scripts/tests/test_focus_history_isolated.py b/scripts/tests/test_focus_history_isolated.py new file mode 100644 index 0000000..682bebc --- /dev/null +++ b/scripts/tests/test_focus_history_isolated.py @@ -0,0 +1,114 @@ +"""Focus history against real windows on a disposable nested compositor. + +FERESE_TEST_FOCUS_HISTORY=1 python3 scripts/tests/test_focus_history_isolated.py +Requires built debug ferese/feresectl, foot, dbus-daemon, and a Wayland host. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +import unittest + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_FOCUS_HISTORY") == "1", "nested focus test is opt-in") +class FocusHistoryTest(unittest.TestCase): + def test_last_focus_crosses_workspaces_and_skips_closed_windows(self): + repo = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="ferese-focus-test-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text("animations { enabled #false; }\n") + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state")) + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + children = [] + + def launch(command, **kwargs): + child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + children.append(child) + return child + + def command(*args): + return json.loads(subprocess.check_output( + [str(repo / "target/debug/feresectl"), *args], env=env, timeout=5 + )) + + def wait_for(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(.025) + self.fail("Timed out waiting for compositor state: " + (root / "compositor.log").read_text()) + + def focused(): + result = command("get-focused-window") + return result["id"] if result else None + + with (root / "compositor.log").open("w") as log: + try: + bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, text=True) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) + wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + command("focus-last-window") + self.assertIsNone(focused()) + env["WAYLAND_DISPLAY"] = str(next( + path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock") + )) + windows = [] + for index in range(3): + app = f"ferese.test.focus.{index}" + child = launch(["foot", "--app-id", app, "sleep", "120"], stdout=log, stderr=log) + window = wait_for(lambda: next((w for w in command("get-windows") + if w["app_id"] == app and w["width"] > 0), None)) + wait_for(lambda: focused() == window["id"]) + windows.append((child, window["id"])) + if index == 1: + command("workspace", "2") + first, second, third = [window for _, window in windows] + command("focus-last-window") + self.assertEqual(focused(), second) + self.assertEqual(next(w["name"] for w in command("get-workspaces") if w["active"]), "1") + command("focus-last-window") + self.assertEqual(focused(), third) + self.assertEqual(next(w["name"] for w in command("get-workspaces") if w["active"]), "2") + command("focus-mru-previous") + self.assertEqual(focused(), first, "reverse traversal reaches least recent window") + command("focus-mru-next") + self.assertEqual(focused(), third) + windows[1][0].terminate() + windows[1][0].wait(timeout=3) + wait_for(lambda: not any(w["id"] == second for w in command("get-windows"))) + command("focus-last-window") + self.assertEqual(focused(), first, "closed windows must not consume a focus action") + command("reload-config") + command("focus-last-window") + self.assertEqual(focused(), third, "reload must preserve focus history") + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=3) + if child.stdout is not None: + child.stdout.close() + + +if __name__ == "__main__": + unittest.main()