Add MRU focus switching between tiled and floating layers
This commit is contained in:
1 parent
ce024d4794
commit
76112b59ac
9 files changed
+364
-4
No files matched your search
@@ -484,11 +484,23 @@ mod tests {
|
||||
assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_floating_is_bound_to_super_v_and_accepts_a_custom_binding() {
|
||||
for source in ["", "binding \"Super+V\" \"focus-floating\"\n"] {
|
||||
let configured = parse(source).runtime_config().unwrap();
|
||||
assert!(configured.bindings.iter().any(|binding| {
|
||||
binding.action == BindingAction::FocusFloating
|
||||
&& binding.matches(55_u32.into(), &[keysyms::KEY_v], true, false, false, false)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_history_actions_are_bindable_and_have_defaults() {
|
||||
let defaults = Config::default().runtime_config().unwrap();
|
||||
for action in [
|
||||
BindingAction::FocusLastWindow,
|
||||
BindingAction::FocusFloating,
|
||||
BindingAction::FocusMru(false),
|
||||
BindingAction::FocusMru(true),
|
||||
] {
|
||||
@@ -496,6 +508,7 @@ mod tests {
|
||||
}
|
||||
for (name, action) in [
|
||||
("focus-last-window", BindingAction::FocusLastWindow),
|
||||
("focus-floating", BindingAction::FocusFloating),
|
||||
("focus-mru-next", BindingAction::FocusMru(false)),
|
||||
("focus-mru-previous", BindingAction::FocusMru(true)),
|
||||
] {
|
||||
@@ -1056,7 +1069,7 @@ mod tests {
|
||||
let input = config.input_settings().unwrap();
|
||||
let bindings = config.bindings(&input).unwrap();
|
||||
|
||||
assert_eq!(bindings.len(), 58);
|
||||
assert_eq!(bindings.len(), 59);
|
||||
for (shift, action) in [
|
||||
(false, BindingAction::ToggleMaximized),
|
||||
(true, BindingAction::ToggleFullscreen),
|
||||
@@ -1100,7 +1113,7 @@ mod tests {
|
||||
|
||||
let input = replaced.input_settings().unwrap();
|
||||
let bindings = replaced.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 58);
|
||||
assert_eq!(bindings.len(), 59);
|
||||
assert!(bindings.iter().any(|binding| {
|
||||
binding.action
|
||||
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
||||
@@ -1108,7 +1121,7 @@ mod tests {
|
||||
|
||||
let input = unbound.input_settings().unwrap();
|
||||
let bindings = unbound.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 57);
|
||||
assert_eq!(bindings.len(), 58);
|
||||
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,10 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusLastWindow)
|
||||
}
|
||||
"focus-floating" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusFloating)
|
||||
}
|
||||
"focus-mru-next" | "focus-mru-previous" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::FocusMru(action == "focus-mru-previous"))
|
||||
@@ -391,6 +395,7 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
binding("Super+[", "consume", None),
|
||||
binding("Super+]", "expel", None),
|
||||
binding("Super+Shift+Space", "toggle-floating", None),
|
||||
binding("Super+V", "focus-floating", None),
|
||||
binding("Super+Tab", "toggle-overview", None),
|
||||
binding("XF86Display", "toggle-display-mode", None),
|
||||
binding("XF86AudioPlay", "media-play-pause", None),
|
||||
@@ -467,6 +472,7 @@ pub enum BindingAction {
|
||||
Exit,
|
||||
Focus(ferese_layout::Direction),
|
||||
FocusLastWindow,
|
||||
FocusFloating,
|
||||
FocusMru(bool),
|
||||
Move(ferese_layout::Direction),
|
||||
Resize(ferese_layout::Direction),
|
||||
@@ -572,6 +578,7 @@ impl Binding {
|
||||
BindingAction::Exit => "Log out (with confirmation)".into(),
|
||||
BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
|
||||
BindingAction::FocusLastWindow => "Return to last focused window".into(),
|
||||
BindingAction::FocusFloating => "Switch focus between tiled and floating windows".into(),
|
||||
BindingAction::FocusMru(reverse) => if *reverse {
|
||||
"Previous window in focus history"
|
||||
} else {
|
||||
|
||||
@@ -1094,6 +1094,7 @@ impl Ferese {
|
||||
BindingAction::Exit => self.request_logout_confirmation(),
|
||||
BindingAction::Focus(direction) => self.focus_direction(direction),
|
||||
BindingAction::FocusLastWindow => self.focus_last_window(),
|
||||
BindingAction::FocusFloating => self.focus_floating(),
|
||||
BindingAction::FocusMru(reverse) => self.cycle_focus(reverse, false),
|
||||
BindingAction::Move(direction) => self.move_direction(direction),
|
||||
BindingAction::Resize(direction) => self.resize_direction(direction),
|
||||
|
||||
@@ -855,6 +855,7 @@ impl Ferese {
|
||||
.map_err(|e| CommandError::new("invalid_config", e))?,
|
||||
"focus" => self.focus_direction(direction_arg(args)?),
|
||||
"focus-last-window" => self.focus_last_window(),
|
||||
"focus-floating" => self.focus_floating(),
|
||||
"focus-mru-next" => self.cycle_focus(false, false),
|
||||
"focus-mru-previous" => self.cycle_focus(true, false),
|
||||
"move" => self.move_direction(direction_arg(args)?),
|
||||
|
||||
@@ -38,6 +38,35 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn focus_floating(&mut self) {
|
||||
if self.session_lock.active() || self.input_capture.captures(1) {
|
||||
return;
|
||||
}
|
||||
let Some(current) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let workspace = self.workspaces.active_id();
|
||||
if self.workspaces.workspace_for_window(current) != Some(workspace) {
|
||||
return;
|
||||
}
|
||||
let target = self
|
||||
.focus_history
|
||||
.candidates(self.focused_window, self.focus_candidates())
|
||||
.into_iter()
|
||||
.find(|id| {
|
||||
self.workspaces.workspace_for_window(*id) == Some(workspace)
|
||||
&& matches!(
|
||||
(self.workspaces.placement(current), self.workspaces.placement(*id)),
|
||||
(Some(WindowPlacement::Tiled), Some(WindowPlacement::Floating { .. }))
|
||||
| (Some(WindowPlacement::Floating { .. }), Some(WindowPlacement::Tiled))
|
||||
)
|
||||
});
|
||||
if let Some(id) = target {
|
||||
self.cancel_focus_cycle();
|
||||
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;
|
||||
@@ -96,3 +125,267 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::capture_privacy::tests::window;
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
struct Fixture {
|
||||
events: EventLoop<'static, Ferese>,
|
||||
state: Ferese,
|
||||
ids: Vec<WindowId>,
|
||||
_clients: Vec<UnixStream>,
|
||||
}
|
||||
|
||||
fn fixture(test: &str, floating: &[bool]) -> Option<Fixture> {
|
||||
if !crate::startup_tests::private_runtime(&format!("state::focus::tests::{test}")) {
|
||||
return None;
|
||||
}
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
state.animations_enabled = false;
|
||||
let output = Output::new(
|
||||
"focus".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
output.change_current_state(
|
||||
Some(smithay::output::Mode {
|
||||
size: (800, 600).into(),
|
||||
refresh: 60_000,
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.space.map_output(&output, (0, 0));
|
||||
state.register_output(&output, "focus".into());
|
||||
let mut ids = Vec::new();
|
||||
let mut clients = Vec::new();
|
||||
for floating in floating {
|
||||
let (window, wire) = window(&mut state, &mut events, 0xff112233);
|
||||
let id = state.windows.ids()[&window];
|
||||
assert!(state.activate_managed_window(id));
|
||||
if *floating {
|
||||
state.toggle_focused_floating();
|
||||
}
|
||||
ids.push(id);
|
||||
clients.push(wire);
|
||||
}
|
||||
Some(Fixture {
|
||||
events,
|
||||
state,
|
||||
ids,
|
||||
_clients: clients,
|
||||
})
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn focus(&mut self, index: usize) {
|
||||
assert!(self.state.activate_managed_window(self.ids[index]));
|
||||
}
|
||||
|
||||
fn placements(&self) -> Vec<Option<WindowPlacement>> {
|
||||
self.ids.iter().map(|id| self.state.workspaces.placement(*id)).collect()
|
||||
}
|
||||
|
||||
fn press(&mut self, expected: usize) {
|
||||
let workspace = self.state.workspaces.active_id();
|
||||
let placements = self.placements();
|
||||
self.state.focus_floating();
|
||||
self.assert_focus(expected);
|
||||
assert_eq!(self.state.workspaces.active_id(), workspace);
|
||||
assert_eq!(self.placements(), placements);
|
||||
}
|
||||
|
||||
fn assert_focus(&self, expected: usize) {
|
||||
let id = self.ids[expected];
|
||||
assert_eq!(self.state.focused_window, Some(id));
|
||||
assert_eq!(self.state.workspaces.active().last_focused, Some(id));
|
||||
assert_eq!(
|
||||
self.state.seat.get_keyboard().unwrap().current_focus(),
|
||||
Some(
|
||||
self.state
|
||||
.windows
|
||||
.window(id)
|
||||
.unwrap()
|
||||
.toplevel()
|
||||
.unwrap()
|
||||
.wl_surface()
|
||||
.clone()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_focuses_most_recently_focused_float() {
|
||||
let Some(mut f) = fixture("tiled_focuses_most_recently_focused_float", &[false, true, true]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(1);
|
||||
f.focus(0);
|
||||
f.press(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_focuses_most_recently_focused_tile() {
|
||||
let Some(mut f) = fixture("floating_focuses_most_recently_focused_tile", &[false, false, true]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(0);
|
||||
f.focus(2);
|
||||
f.press(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_floaters_do_not_cycle_or_wrap() {
|
||||
let Some(mut f) = fixture("multiple_floaters_do_not_cycle_or_wrap", &[false, true, true, true]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(2);
|
||||
f.focus(0);
|
||||
for _ in 0..3 {
|
||||
f.press(2);
|
||||
f.press(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_floating_candidate_is_a_no_op() {
|
||||
let Some(mut f) = fixture("no_floating_candidate_is_a_no_op", &[false, false]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(0);
|
||||
let history = f.state.focus_history.candidates(None, &f.ids);
|
||||
f.press(0);
|
||||
f.press(0);
|
||||
assert_eq!(f.state.focus_history.candidates(None, &f.ids), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tiled_candidate_is_a_no_op() {
|
||||
let Some(mut f) = fixture("no_tiled_candidate_is_a_no_op", &[true, true]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(0);
|
||||
let history = f.state.focus_history.candidates(None, &f.ids);
|
||||
f.press(0);
|
||||
f.press(0);
|
||||
assert_eq!(f.state.focus_history.candidates(None, &f.ids), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_on_other_workspaces_are_ignored_in_both_directions() {
|
||||
let Some(mut f) = fixture(
|
||||
"candidates_on_other_workspaces_are_ignored_in_both_directions",
|
||||
&[false, true, false, true],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
for index in [2, 3] {
|
||||
f.focus(index);
|
||||
f.state.move_focused_to_workspace(2);
|
||||
}
|
||||
f.focus(1);
|
||||
f.focus(3);
|
||||
f.focus(0);
|
||||
f.press(1);
|
||||
f.focus(2);
|
||||
f.focus(1);
|
||||
f.press(0);
|
||||
f.focus(0);
|
||||
f.state.move_focused_to_workspace(2);
|
||||
f.focus(1);
|
||||
f.press(1);
|
||||
f.focus(3);
|
||||
f.state.move_focused_to_workspace(1);
|
||||
f.focus(2);
|
||||
f.press(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_presses_follow_updated_focus_history_between_layers() {
|
||||
let Some(mut f) = fixture(
|
||||
"repeated_presses_follow_updated_focus_history_between_layers",
|
||||
&[false, false, true, true],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
f.focus(0);
|
||||
f.focus(2);
|
||||
for _ in 0..3 {
|
||||
f.press(0);
|
||||
f.press(2);
|
||||
}
|
||||
f.focus(1);
|
||||
f.press(2);
|
||||
f.press(1);
|
||||
f.focus(3);
|
||||
f.press(1);
|
||||
f.press(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_floating_preserves_all_window_placements() {
|
||||
let Some(mut f) = fixture("focus_floating_preserves_all_window_placements", &[false, true, true]) else {
|
||||
return;
|
||||
};
|
||||
let placements = f.placements();
|
||||
f.focus(0);
|
||||
f.press(2);
|
||||
f.press(0);
|
||||
assert_eq!(f.placements(), placements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipc_focus_floating_uses_the_managed_activation_path() {
|
||||
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
let Some(mut f) = fixture("ipc_focus_floating_uses_the_managed_activation_path", &[false, true]) else {
|
||||
return;
|
||||
};
|
||||
f.focus(0);
|
||||
let workspace = f.state.workspaces.active_id();
|
||||
let placements = f.placements();
|
||||
let path = f.state.session_environment.control_socket.clone();
|
||||
let (send, receive) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut socket = UnixStream::connect(path).unwrap();
|
||||
socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
write_frame(
|
||||
&mut socket,
|
||||
&Request {
|
||||
version: VERSION,
|
||||
id: 1,
|
||||
kind: "command".into(),
|
||||
command: "focus-floating".into(),
|
||||
args: serde_json::json!({}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
send.send(read_frame::<Response>(&mut socket).unwrap()).unwrap();
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let response = loop {
|
||||
if let Ok(response) = receive.try_recv() {
|
||||
break response;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "IPC focus action timed out");
|
||||
f.events.dispatch(Duration::from_millis(1), &mut f.state).unwrap();
|
||||
};
|
||||
worker.join().unwrap();
|
||||
assert!(response.error.is_none(), "{:?}", response.error);
|
||||
f.assert_focus(1);
|
||||
assert_eq!(f.state.workspaces.active_id(), workspace);
|
||||
assert_eq!(f.placements(), placements);
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,7 @@ invalid. A disabled binding must omit `action` and `argument`.
|
||||
| `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-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace |
|
||||
| `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 |
|
||||
@@ -527,13 +528,18 @@ commands {
|
||||
terminal "foot"
|
||||
}
|
||||
binding keys="Super+Enter" action="spawn" argument="terminal"
|
||||
binding keys="Super+V" action="focus-floating"
|
||||
```
|
||||
|
||||
`focus-floating` switches focus between tiled and floating windows without
|
||||
changing placement. It stays on the active workspace and does nothing when the
|
||||
opposite layer has no candidate. `toggle-floating` changes window placement.
|
||||
|
||||
Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus;
|
||||
Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace;
|
||||
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+M layout; Super+Shift+Space floating; Super+V focus tiled/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;
|
||||
|
||||
+10
@@ -35,6 +35,16 @@ feresectl screenshot -g '0,0 800x600' > region.png
|
||||
feresectl screenshot-window 7 > window.png
|
||||
```
|
||||
|
||||
## Window focus
|
||||
|
||||
```sh
|
||||
feresectl focus-floating
|
||||
```
|
||||
|
||||
`focus-floating` focuses the most recently focused window in the opposite
|
||||
tiled/floating layer on the active workspace. It does nothing when that layer
|
||||
has no candidate. `toggle-floating` changes placement; `focus-floating` changes focus.
|
||||
|
||||
## Choose a session
|
||||
|
||||
Every feresectl IPC command, including theme commands and subscriptions, resolves
|
||||
|
||||
@@ -80,6 +80,8 @@ pub enum Command {
|
||||
WorkspaceBackAndForth,
|
||||
/// Focus the previously focused window.
|
||||
FocusLastWindow,
|
||||
/// Focus the most recently focused window in the opposite tiled/floating layer on this workspace.
|
||||
FocusFloating,
|
||||
/// Focus the next recently used window.
|
||||
FocusMruNext,
|
||||
/// Focus the previous recently used window.
|
||||
@@ -239,6 +241,7 @@ impl Command {
|
||||
Self::RequestLogout => ("request-logout", json!({})),
|
||||
Self::WorkspaceBackAndForth => ("workspace-back-and-forth", json!({})),
|
||||
Self::FocusLastWindow => ("focus-last-window", json!({})),
|
||||
Self::FocusFloating => ("focus-floating", json!({})),
|
||||
Self::FocusMruNext => ("focus-mru-next", json!({})),
|
||||
Self::FocusMruPrevious => ("focus-mru-previous", json!({})),
|
||||
Self::Focus { direction } => ("focus", json!({"direction": value_name(direction)})),
|
||||
|
||||
@@ -41,6 +41,7 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() {
|
||||
&["media", "--help"],
|
||||
&["xwayland", "--help"],
|
||||
&["screenshot", "--help"],
|
||||
&["focus-floating", "--help"],
|
||||
] {
|
||||
let text = success(ctl(args).output().unwrap());
|
||||
assert!(text.contains("Usage:"));
|
||||
@@ -52,6 +53,31 @@ fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() {
|
||||
assert!(error.contains("windows"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_floating_sends_a_parameterless_action_and_rejects_cycle_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let socket = directory.path().join("control.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let worker = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let request: Request = read_frame(&mut stream).unwrap();
|
||||
assert_eq!(request.command, "focus-floating");
|
||||
assert_eq!(request.args, json!({}));
|
||||
write_frame(&mut stream, &Response::success(request.id, json!({}))).unwrap();
|
||||
});
|
||||
success(
|
||||
ctl(&["--socket", socket.to_str().unwrap(), "focus-floating"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
worker.join().unwrap();
|
||||
let output = ctl(&["focus-floating", "next"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_socket_targets_the_nested_instance_for_queries_and_theme_commands() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user