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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user