Add focus history and Alt-Tab window switching
This commit is contained in:
1 parent
a3431cab5b
commit
0162870cca
18 files changed
+571
-8
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)?),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user