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

+3
View File
@@ -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 |
+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);
+144
View File
@@ -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<WindowId>,
}
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<WindowId>, mut available: Vec<WindowId>) -> Vec<WindowId> {
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<WindowId>,
selected: Option<WindowId>,
}
impl FocusCycle {
pub fn new(windows: Vec<WindowId>, current: Option<WindowId>) -> 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<WindowId> {
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<WindowId> {
// 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)));
}
}
+2
View File
@@ -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,
+19
View File
@@ -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.
+9 -2
View File
@@ -159,7 +159,10 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> 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<PathBuf, io::Error> {
}
fn usage() -> String {
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".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]
@@ -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()