Add focus history and Alt-Tab window switching

This commit is contained in:
Samuel Onoja committed 2026-10-01 16:00:23 +01:00
1 parent a3431cab5b
commit 0162870cca
18 files changed
+571 -8

No files matched your search

+40 -3
View File
@@ -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));
}
+20
View File
@@ -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<BindingConfig> {
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}"),
+10
View File
@@ -59,6 +59,16 @@ impl SeatHandler for Ferese {
}
fn focus_changed(&mut self, seat: &Seat<Self>, 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()
{
+4 -2
View File
@@ -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()
+33 -1
View File
@@ -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),
+3
View File
@@ -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)?),
+3
View File
@@ -116,6 +116,9 @@ fn main() -> Result<(), Box<dyn Error>> {
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();
+58
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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);
+5
View File
@@ -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<WindowId, (WorkspaceId, AnimatedValue)>,
pending_column_width_cycles: HashSet<WindowId>,
pub focused_window: Option<WindowId>,
pub(crate) focus_history: ferese_core::FocusHistory,
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
column_width_presets: Vec<ColumnWidth>,
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,
+100
View File
@@ -0,0 +1,100 @@
use super::*;
impl Ferese {
pub(super) fn focus_candidates(&self) -> Vec<WindowId> {
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<OutputId> {
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);
}
}
}
+3
View File
@@ -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
+1
View File
@@ -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);