diff --git a/README.md b/README.md index 1f2874e8..90970b17 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ While it runs, the daemon takes messages on a Unix socket, `komorebi.sock` in `$XDG_RUNTIME_DIR/komorebi` (the local data directory when there is no runtime directory): a message can ask for the state of the window manager, ask which monitor, workspace or container has the focus, give the focus to another -window, move the focused window to another tile, retile every monitor, or stop -the daemon. [`komorebi-client`](komorebi-client) writes them, and `komorebic`, +window, move the focused window to another tile, change the layout, retile +every monitor, or stop the daemon. [`komorebi-client`](komorebi-client) writes them, and `komorebic`, the command line, is how a person sends them. `komorebic start` runs the `komorebi` on the `PATH`, and passes it what follows a `--`. In a Plasma session: @@ -135,7 +135,9 @@ message that gives an order changes the model the way an event does: `focus` and `cycle-focus` give the focus to the container on one side of the focused one in the layout, or to the next one in the workspace's order, and `move` and `cycle-move` swap the focused container with that one and tile the workspace -again. +again. `change-layout`, `cycle-layout` and `flip-layout` tile the workspace in +another layout, `toggle-tiling` stops it moving windows and starts it again, +and `preselect-direction` reserves a tile for the next window. A program such as a status bar can subscribe to the daemon's notifications: it listens on a socket of its own, and after each event and each message, the diff --git a/docs/cli/cancel-preselect.md b/docs/cli/cancel-preselect.md new file mode 100644 index 00000000..6b074090 --- /dev/null +++ b/docs/cli/cancel-preselect.md @@ -0,0 +1,10 @@ +```text +Cancel a workspace preselect set by the preselect-direction command, if one exists + +Usage: cancel-preselect + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/change-layout.md b/docs/cli/change-layout.md new file mode 100644 index 00000000..55bd2f4c --- /dev/null +++ b/docs/cli/change-layout.md @@ -0,0 +1,23 @@ +```text +Set the layout on the focused workspace + +Usage: change-layout + +Arguments: + + Possible values: + - bsp: BSP Layout + - columns: Columns Layout + - rows: Rows Layout + - vertical-stack: Vertical Stack Layout + - horizontal-stack: Horizontal Stack Layout + - ultrawide-vertical-stack: Ultrawide Vertical Stack Layout + - grid: Grid Layout + - right-main-vertical-stack: Right Main Vertical Stack Layout + - scrolling: Scrolling Layout + +Options: + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/docs/cli/cycle-layout.md b/docs/cli/cycle-layout.md new file mode 100644 index 00000000..4652d060 --- /dev/null +++ b/docs/cli/cycle-layout.md @@ -0,0 +1,14 @@ +```text +Cycle between available layouts + +Usage: cycle-layout + +Arguments: + + [possible values: previous, next] + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/flip-layout.md b/docs/cli/flip-layout.md new file mode 100644 index 00000000..1c79e459 --- /dev/null +++ b/docs/cli/flip-layout.md @@ -0,0 +1,17 @@ +```text +Flip the layout on the focused workspace + +Usage: flip-layout + +Arguments: + + Possible values: + - horizontal: Horizontal axis + - vertical: Vertical axis + - horizontal-and-vertical: Both horizontal and vertical axes + +Options: + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/docs/cli/preselect-direction.md b/docs/cli/preselect-direction.md new file mode 100644 index 00000000..3d5697cf --- /dev/null +++ b/docs/cli/preselect-direction.md @@ -0,0 +1,14 @@ +```text +Preselect the specified direction for the next window to be spawned on supported layouts + +Usage: preselect-direction + +Arguments: + + [possible values: left, right, up, down] + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/toggle-tiling.md b/docs/cli/toggle-tiling.md new file mode 100644 index 00000000..6c8d4daa --- /dev/null +++ b/docs/cli/toggle-tiling.md @@ -0,0 +1,10 @@ +```text +Toggle window tiling on the focused workspace + +Usage: toggle-tiling + +Options: + -h, --help + Print help + +``` diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 9ea8b995..5aa3d1d6 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -27,7 +27,7 @@ its place here. - **Event**: what the compositor reports about a window: it was shown, destroyed, given the focus, minimized or restored ([`WindowManagerEvent`]). - **Message**: what a client sends the daemon on its socket, a [`SocketMessage`]: an order - (focus a window, move it, retile, stop) or a question (the state, the monitors, one [`StateQuery`]). + (focus a window, move it, change the layout, retile, stop) or a question (the state, the monitors, one [`StateQuery`]). - **Direction**: where an order goes from the focused container: a side of its tile ([`OperationDirection`]), or the next or the previous container of the workspace ([`CycleDirection`]). @@ -262,6 +262,22 @@ through `LockableSequence`, so a lock stays with its index: the container swappe index is locked in its place. When there is no container in that direction, the message fails and nothing moves. +`ChangeLayout` and `CycleLayout` set the focused workspace's [`Layout`], and `FlipLayout` +mirrors it along an axis, on top of the flip it has ([`Workspace::layout_flip`]); each tiles +the workspace again ([`WindowManager::change_workspace_layout_default`], +[`WindowManager::cycle_layout`], [`WindowManager::flip_layout`]). A flip mirrors the tiles, +not the order of the containers, and a direction is flipped with them, so `focus left` on a +flipped layout goes to the tile on the left. `ToggleTiling` turns the workspace's tiling off +and on ([`Workspace::tile`]): with it off, [`Workspace::update`] moves no window, the workspace +still takes the windows that open into containers, and they are tiled when it is turned on. + +`PreselectDirection` reserves the index on one side of the focused container for the next +window ([`WindowManager::preselect_container_in_direction`]). The workspace inserts an empty +container there ([`Container::preselect`]), which [`Workspace::update`] keeps and the layout +gives a tile, and [`Workspace::new_container_for_window`] puts the next window in it. +`CancelPreselect` removes it. A workspace reserves one place at a time, and the grid layout +none. + ## The NixOS module The flake exports one module under two names, `nixosModules.default` and @@ -312,7 +328,7 @@ module's unit. changes its containers. - [`window_manager`](../../komorebi/src/window_manager.rs): the [`WindowManager`] and the operations on it (tile the workspace in front, tile every monitor, take a minimized - window out, move the focus, move the focused container). + window out, move the focus, move the focused container, change the layout). - [`desktop`](../../komorebi/src/desktop.rs): loads the monitors and the open windows from the backend. - [`window_manager_event`](../../komorebi/src/window_manager_event.rs) and @@ -395,6 +411,7 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | A socket left behind by a daemon that did not stop cleanly does not keep the next one from starting | framework path: [`WindowManager::new`] removes the file before it binds | `a_socket_file_left_behind_is_replaced` | none | | A daemon that is told to stop leaves no socket behind | framework path: [`WindowManager::stop`] removes it before it exits | `the_daemon_answers_on_its_socket_and_stops_when_it_is_told_to` | `SIGKILL` | | A container moved onto a locked index is locked in its place, and the lock leaves the container that moved away | framework path: [`Workspace::swap_containers`] swaps through `LockableSequence`, which keeps the lock with the index | `test_swap_respecting_locks_various_cases`, `a_lock_stays_with_its_index_when_a_window_is_moved_onto_it` | `containers_mut()` is the `VecDeque` itself | +| A workspace whose tiling is off moves no window | framework path: [`Workspace::update`] returns before the layout when [`Workspace::tile`] is false | `with_tiling_off_no_window_is_moved_until_it_is_back_on` | none | | Moving the focus moves no window | test | `focus_gives_the_focus_to_the_window_on_that_side_and_moves_nothing`, `vm-core-e2e` ("moving the focus moved no window") | none | | A subscriber that is gone is dropped, not retried | test | `a_subscriber_that_is_gone_is_dropped_and_not_tried_again` | none | | The bridge holds no window manager logic | convention, with every decision about an event in the table, which the `kwin::state` unit tests pin | the unit tests in `komorebi/src/kwin/state.rs` | none | @@ -503,9 +520,12 @@ A window is shown, and ends up on a tile: driven end to end against the fake. - [`komorebi/tests/socket.rs`](../../komorebi/tests/socket.rs): the daemon over its socket, from its start to a `Stop`. -- [`komorebi/tests/focus.rs`](../../komorebi/tests/focus.rs) and - [`komorebi/tests/moving.rs`](../../komorebi/tests/moving.rs): the focus and move commands - against the fake. +- [`komorebi/tests/focus.rs`](../../komorebi/tests/focus.rs), + [`komorebi/tests/moving.rs`](../../komorebi/tests/moving.rs) and + [`komorebi/tests/layouts.rs`](../../komorebi/tests/layouts.rs): the focus, move and layout + commands against the fake. +- [`nix/tests/vm/suites/commands`](../../nix/tests/vm/suites/commands/test.py): every layout, + and every `komorebic` command, against a real KWin with two outputs. - [`nix/nixos-module.nix`](../../nix/nixos-module.nix): the NixOS module; [`nix/tests/nixos-module.nix`](../../nix/tests/nixos-module.nix), its scenarios, evaluated in real NixOS systems; and [`nix/tests/vm/suites/module`](../../nix/tests/vm/suites/module/test.py), @@ -537,6 +557,14 @@ A window is shown, and ends up on a tile: [`WindowManager::move_container_in_direction`]: ../../komorebi/src/window_manager.rs [`WindowManager::move_container_in_cycle_direction`]: ../../komorebi/src/window_manager.rs [`Workspace::swap_containers`]: ../../komorebi/src/workspace.rs +[`Workspace::layout_flip`]: ../../komorebi/src/workspace.rs +[`Workspace::tile`]: ../../komorebi/src/workspace.rs +[`Layout`]: ../../komorebi/src/core/mod.rs +[`Container::preselect`]: ../../komorebi/src/container.rs +[`WindowManager::change_workspace_layout_default`]: ../../komorebi/src/window_manager.rs +[`WindowManager::cycle_layout`]: ../../komorebi/src/window_manager.rs +[`WindowManager::flip_layout`]: ../../komorebi/src/window_manager.rs +[`WindowManager::preselect_container_in_direction`]: ../../komorebi/src/window_manager.rs [`Workspace::new_idx_for_direction`]: ../../komorebi/src/workspace.rs [`Workspace::new_idx_for_cycle_direction`]: ../../komorebi/src/workspace.rs [`OperationDirection`]: ../../komorebi/src/core/mod.rs diff --git a/docs/dev/vm-tests.md b/docs/dev/vm-tests.md index edc86552..dbd17385 100644 --- a/docs/dev/vm-tests.md +++ b/docs/dev/vm-tests.md @@ -126,8 +126,9 @@ the run. - `komorebic(*args)` runs `komorebic` in the session and returns what it printed, failing the test if it fails. `state()` is the daemon's state as `komorebic state` prints it, `managed(workspace)` the number of windows the - daemon has on a workspace of the first monitor, and `focused_title(workspace)` - the title of the window it has focused there. + daemon has on a workspace of the first monitor, `managed_all()` the number it + has on every workspace of every monitor, and `focused_title(workspace)` the + title of the window it has focused there. - `daemon` reads the daemon's view of the desktop from that log, at the debug level the nodes set: `daemon.events(log)` lists the window events it handled, in order, `daemon.window_ids(log)` maps a window's title to the daemon's id diff --git a/docs/index.md b/docs/index.md index 7c39a4a0..8da35f5d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,7 +33,8 @@ the flake's module, which also starts komorebi with every Plasma session, and fr anywhere else. [Running komorebi](usage/running-komorebi.md) explains how to start and stop the daemon, ask it what it is doing and read its log. [Focusing windows](usage/focusing-windows.md) explains how to give the focus to another window, -and [Moving windows](usage/moving-windows.md) how to move the focused window to another tile. +[Moving windows](usage/moving-windows.md) how to move the focused window to another tile, and +[Layouts](usage/layouts.md) how to change the arrangement of the tiles. [Subscribing to notifications](usage/subscribing-to-notifications.md) explains how a program such as a status bar follows what komorebi does. The [CLI reference](cli/start.md) has a page for every `komorebic` command, generated from its help. diff --git a/docs/usage/layouts.md b/docs/usage/layouts.md new file mode 100644 index 00000000..1d01b011 --- /dev/null +++ b/docs/usage/layouts.md @@ -0,0 +1,80 @@ +# Layouts + +The layout of a workspace turns its work area and the number of its containers into one tile +for each container. Every workspace starts with the BSP layout. A command that changes the +layout tiles the focused workspace again at once. + +## The layouts + +These are the layouts of `komorebi-layouts`, in the order `cycle-layout next` goes through them: + +| Layout | `komorebic` name | The tiles | +|---|---|---| +| BSP | `bsp` | each new tile splits the last one in two, alternately side by side and one above the other | +| Columns | `columns` | side by side, as wide as each other | +| Rows | `rows` | one above the other, as high as each other | +| Vertical stack | `vertical-stack` | the first on the left half, the others stacked on the right | +| Horizontal stack | `horizontal-stack` | the first on the top half, the others side by side below it | +| Ultrawide vertical stack | `ultrawide-vertical-stack` | the first in the middle, the second on the left, the others stacked on the right | +| Grid | `grid` | columns of tiles, as even as the number of containers allows | +| Right main vertical stack | `right-main-vertical-stack` | the first on the right half, the others stacked on the left | +| Scrolling | `scrolling` | columns side by side | + +## Changing the layout + +[`komorebic change-layout`](../cli/change-layout.md) gives the focused workspace the layout it +names, and [`komorebic cycle-layout`](../cli/cycle-layout.md) the next or the previous one in +the table above: + +```sh +komorebic change-layout vertical-stack +komorebic cycle-layout next +komorebic cycle-layout previous +``` + +`cycle-layout next` goes from the scrolling layout back to BSP. Going backwards, +`komorebi-layouts` leaves the scrolling layout out: `cycle-layout previous` goes from BSP to the +right main vertical stack. + +[`komorebic query focused-workspace-layout`](../cli/query.md) prints the layout of the focused +workspace. + +## Flipping the layout + +[`komorebic flip-layout`](../cli/flip-layout.md) mirrors the tiles of the focused workspace: +`horizontal` swaps left and right, `vertical` top and bottom, and `horizontal-and-vertical` +both. A flip is added to the one the workspace has, so the same flip twice puts the layout +back. + +```sh +komorebic flip-layout horizontal +komorebic flip-layout horizontal +``` + +The containers keep their order, and the directions of [`focus`](focusing-windows.md) and +[`move`](moving-windows.md) are the ones on the screen: after `flip-layout horizontal`, +`focus left` goes to the tile on the left. + +## Turning tiling off + +[`komorebic toggle-tiling`](../cli/toggle-tiling.md) turns the tiling of the focused workspace +off, and on again. While it is off, komorebi moves none of its windows: they stay where they +are, and can be moved by hand. Windows that open on the workspace are still taken into it, and +when tiling is turned back on, every window is tiled again. `query focused-workspace-layout` +prints `None` while tiling is off. + +## Preselecting a tile + +[`komorebic preselect-direction`](../cli/preselect-direction.md) reserves the place on one side +of the focused window for the next window that opens on the workspace. The layout gives the +reserved place a tile at once, empty until the window opens. +[`komorebic cancel-preselect`](../cli/cancel-preselect.md) gives the place up. + +```sh +komorebic preselect-direction left +komorebic cancel-preselect +``` + +The place is a position in the order of the workspace, so where its tile is depends on the +layout. A workspace reserves one place at a time: a second `preselect-direction` is ignored +until the window opens or the place is given up. The grid layout has no preselection. diff --git a/komorebi/src/container.rs b/komorebi/src/container.rs index f7bf047f..b7176388 100644 --- a/komorebi/src/container.rs +++ b/komorebi/src/container.rs @@ -47,6 +47,21 @@ impl Lockable for Container { } impl Container { + /// Returns the empty container that holds the place a preselection reserved + /// ([`Workspace::preselect_container_idx`](crate::workspace::Workspace::preselect_container_idx)) + pub fn preselect() -> Self { + Self { + id: "PRESELECT".to_string(), + locked: false, + windows: Default::default(), + } + } + + /// Returns true if and only if this is the container of a preselection + pub fn is_preselect(&self) -> bool { + self.id == "PRESELECT" + } + pub fn contains_window(&self, window_id: u32) -> bool { for window in self.windows() { if window.id == window_id { diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 91985bf9..0c08554f 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -339,8 +339,18 @@ pub enum SocketMessage { // Window / Container Commands FocusWindow(OperationDirection), MoveWindow(OperationDirection), + PreselectDirection(OperationDirection), + CancelPreselect, CycleFocusWindow(CycleDirection), CycleMoveWindow(CycleDirection), + // Current Workspace Commands + ChangeLayout(DefaultLayout), + CycleLayout(CycleDirection), + // ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), + FlipLayout(Axis), + // Monitor and Workspace Commands + // MonitorIndexPreference(usize, i32, i32, i32, i32), + ToggleTiling, Stop, Retile, State, diff --git a/komorebi/src/monitor.rs b/komorebi/src/monitor.rs index e6208fab..c7c52cf7 100644 --- a/komorebi/src/monitor.rs +++ b/komorebi/src/monitor.rs @@ -212,10 +212,15 @@ impl Monitor { } } - /// The layout of the focused workspace, if the monitor has one + /// The layout of the focused workspace, if the monitor has one and its tiling is on pub fn focused_workspace_layout(&self) -> Option { - self.focused_workspace() - .map(|workspace| workspace.layout.clone()) + self.focused_workspace().and_then(|workspace| { + if workspace.tile { + Some(workspace.layout.clone()) + } else { + None + } + }) } } diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 0b0bba22..7c21c0d3 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -20,6 +20,13 @@ //! of it, or with the next or the previous one, and tile the workspace again //! ([`WindowManager::move_container_in_direction`], //! [`WindowManager::move_container_in_cycle_direction`]). +//! - **ChangeLayout**, **CycleLayout** and **FlipLayout** give the focused workspace another +//! layout, or mirror it, and **ToggleTiling** turns its tiling off and on +//! ([`WindowManager::change_workspace_layout_default`], [`WindowManager::cycle_layout`], +//! [`WindowManager::flip_layout`], [`WindowManager::toggle_tiling`]). +//! - **PreselectDirection** reserves the place on one side of the focused container for the next +//! window ([`WindowManager::preselect_container_in_direction`]), unless a place is reserved +//! already, and **CancelPreselect** gives the reserved place up. //! - **MouseFollowsFocus** and **ToggleMouseFollowsFocus** set whether the pointer is moved to //! the window that is given the focus. //! - **Retile** tiles the focused workspace of every monitor again. @@ -114,6 +121,8 @@ impl WindowManager { /// - The focused container cannot be moved, for one of the reasons /// [`WindowManager::move_container_in_direction`] and /// [`WindowManager::move_container_in_cycle_direction`] give. + /// - There is no focused workspace to change the layout or the tiling of, or to preselect a + /// place in, or it cannot be tiled again. /// - The window manager fails to retile, for one of the reasons /// [`WindowManager::retile_all`] gives. /// - The answer cannot be written to `reply`. @@ -135,6 +144,28 @@ impl WindowManager { SocketMessage::FocusWindow(direction) => { self.focus_container_in_direction(direction)?; } + SocketMessage::PreselectDirection(direction) => { + let focused_workspace = self.focused_workspace()?; + let mut update = false; + + if focused_workspace.preselected_container_idx.is_some() { + tracing::warn!( + "ignoring command as this workspace already has a direction preselect set" + ); + } else { + self.preselect_container_in_direction(direction)?; + update = true; + } + + if update { + self.focused_workspace_mut()?.update()?; + } + } + SocketMessage::CancelPreselect => { + let focused_workspace = self.focused_workspace_mut()?; + focused_workspace.cancel_preselect(); + focused_workspace.update()?; + } SocketMessage::MoveWindow(direction) => { self.move_container_in_direction(direction)?; } @@ -144,7 +175,13 @@ impl WindowManager { SocketMessage::CycleMoveWindow(direction) => { self.move_container_in_cycle_direction(direction)?; } + SocketMessage::FlipLayout(layout_flip) => self.flip_layout(layout_flip)?, + SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?, + SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?, SocketMessage::Retile => self.retile_all()?, + SocketMessage::ToggleTiling => { + self.toggle_tiling()?; + } SocketMessage::State => { let state = match serde_json::to_string_pretty(&State::from(&*self)) { Ok(state) => state, diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index 37cbcc23..68e2d91d 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -80,9 +80,12 @@ impl From<&WindowManager> for State { .map(|workspace| Workspace { containers: workspace.containers.clone(), layout: workspace.layout.clone(), + layout_flip: workspace.layout_flip, workspace_padding: workspace.workspace_padding, container_padding: workspace.container_padding, + tile: workspace.tile, globals: workspace.globals, + preselected_container_idx: None, }) .collect::>(); ws.focus(monitor.workspaces.focused_idx()); diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index 15c4ff4e..171f18ed 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -21,7 +21,10 @@ use crate::LibraryError; use crate::SUBSCRIPTION_SOCKETS; use crate::backend::backend; use crate::container::Container; +use crate::core::Axis; use crate::core::CycleDirection; +use crate::core::DefaultLayout; +use crate::core::Layout; use crate::core::OperationDirection; use crate::core::Rect; use crate::desktop; @@ -120,6 +123,59 @@ impl WindowManager { Ok(()) } + /// Reserves the place on the `direction` side of the focused container for the next window + /// that opens on the focused workspace ([`Workspace::preselect_container_idx`]) + /// + /// The grid layout has no preselection: there, and where there is no container on that side, + /// nothing is reserved. + /// + /// # Errors + /// + /// There is no focused workspace. + #[tracing::instrument(skip(self))] + pub fn preselect_container_in_direction( + &mut self, + direction: OperationDirection, + ) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + let focused_idx = workspace.focused_container_idx(); + + if matches!(workspace.layout, Layout::Default(DefaultLayout::Grid)) { + tracing::warn!("preselection is not supported on the grid layout"); + return Ok(()); + } + + tracing::info!("preselecting container"); + + let new_idx = workspace.new_idx_for_direction(direction); + + match new_idx { + Some(new_idx) => { + let adjusted_idx = match direction { + OperationDirection::Left | OperationDirection::Up => { + if focused_idx.abs_diff(new_idx) == 1 { + new_idx + 1 + } else { + new_idx + } + } + + _ => new_idx, + }; + + workspace.preselect_container_idx(adjusted_idx); + } + + None => { + tracing::debug!( + "this is not a valid preselection direction from the current position" + ) + } + } + + Ok(()) + } + /// Gives the focus to the window of the container whose tile is on the `direction` side of /// the focused one, and the pointer follows it if [`WindowManager::mouse_follows_focus`] is /// set @@ -223,6 +279,21 @@ impl WindowManager { Ok(()) } + /// Gives the focused workspace the layout `layout` and tiles it again + /// + /// # Errors + /// + /// There is no focused workspace, or it cannot be tiled. + #[tracing::instrument(skip(self))] + pub fn change_workspace_layout_default(&mut self, layout: DefaultLayout) -> eyre::Result<()> { + tracing::info!("changing layout"); + + let workspace = self.focused_workspace_mut()?; + + workspace.layout = Layout::Default(layout); + self.update_focused_workspace(self.mouse_follows_focus, false) + } + pub fn focused_workspace_idx(&self) -> eyre::Result { Ok(self .focused_monitor() @@ -344,6 +415,99 @@ impl WindowManager { Ok(()) } + /// Mirrors the layout of the focused workspace along `layout_flip`, on top of the way it is + /// mirrored already, and tiles the workspace again + /// + /// Flipping along an axis the layout is already flipped along undoes that flip, so flipping + /// twice along the same axis puts the layout back. + /// + /// # Errors + /// + /// There is no focused workspace, or it cannot be tiled. + #[tracing::instrument(skip(self))] + pub fn flip_layout(&mut self, layout_flip: Axis) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + + tracing::info!("flipping layout"); + + #[allow(clippy::match_same_arms)] + match workspace.layout_flip { + None => { + workspace.layout_flip = Option::from(layout_flip); + } + Some(current_layout_flip) => { + match current_layout_flip { + Axis::Horizontal => match layout_flip { + Axis::Horizontal => workspace.layout_flip = None, + Axis::Vertical => { + workspace.layout_flip = Option::from(Axis::HorizontalAndVertical) + } + Axis::HorizontalAndVertical => { + workspace.layout_flip = Option::from(Axis::HorizontalAndVertical) + } + }, + Axis::Vertical => match layout_flip { + Axis::Horizontal => { + workspace.layout_flip = Option::from(Axis::HorizontalAndVertical) + } + Axis::Vertical => workspace.layout_flip = None, + Axis::HorizontalAndVertical => { + workspace.layout_flip = Option::from(Axis::HorizontalAndVertical) + } + }, + Axis::HorizontalAndVertical => match layout_flip { + Axis::Horizontal => workspace.layout_flip = Option::from(Axis::Vertical), + Axis::Vertical => workspace.layout_flip = Option::from(Axis::Horizontal), + Axis::HorizontalAndVertical => workspace.layout_flip = None, + }, + }; + } + } + + self.update_focused_workspace(false, false) + } + + /// Gives the focused workspace the next (or the previous) layout, in the order of + /// [`DefaultLayout`], and tiles it again + /// + /// # Errors + /// + /// There is no focused workspace, or it cannot be tiled. + #[tracing::instrument(skip(self))] + pub fn cycle_layout(&mut self, direction: CycleDirection) -> eyre::Result<()> { + tracing::info!("cycling layout"); + + let workspace = self.focused_workspace_mut()?; + + match workspace.layout { + Layout::Default(current) => { + let new_layout = match direction { + CycleDirection::Previous => current.cycle_previous(), + CycleDirection::Next => current.cycle_next(), + }; + + tracing::info!("next layout: {new_layout}"); + workspace.layout = Layout::Default(new_layout); + } + } + + self.update_focused_workspace(self.mouse_follows_focus, false) + } + + /// Turns the tiling of the focused workspace off, or back on and tiles it again + /// + /// While its tiling is off, the workspace keeps its containers and moves no window. + /// + /// # Errors + /// + /// There is no focused workspace, or it cannot be tiled. + #[tracing::instrument(skip(self))] + pub fn toggle_tiling(&mut self) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + workspace.tile = !workspace.tile; + self.update_focused_workspace(false, false) + } + /// Gives the focus to the window of the next (or the previous) container of the focused /// workspace, wrapping around at the ends, and the pointer follows it if /// [`WindowManager::mouse_follows_focus`] is set @@ -524,6 +688,43 @@ mod tests { assert_eq!(wm.focused_container_idx().unwrap(), 0); } + #[test] + fn test_toggle_tiling() { + let (mut wm, _context) = setup_window_manager(); + + { + let mut m = monitor::new( + 0, + Rect::default(), + Rect::default(), + "TestMonitor".to_string(), + "TestDeviceID".to_string(), + ); + + let workspace = m.focused_workspace_mut().unwrap(); + + // Tiling state should be true + assert!(workspace.tile); + + // Add monitor to workspace + wm.monitors_mut().push_back(m); + } + + { + // Tiling state should be false + wm.toggle_tiling().unwrap(); + let workspace = wm.focused_workspace_mut().unwrap(); + assert!(!workspace.tile); + } + + { + // Tiling state should be true + wm.toggle_tiling().unwrap(); + let workspace = wm.focused_workspace_mut().unwrap(); + assert!(workspace.tile); + } + } + #[test] fn a_socket_file_left_behind_is_replaced() -> eyre::Result<()> { let socket_path = PathBuf::from(format!("/tmp/komorebi-test-{}.sock", Uuid::new_v4())); diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index f21e0da0..92e403a0 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -6,6 +6,7 @@ //! falls back to come from the monitor it is on ([`WorkspaceGlobals`]). use crate::container::Container; +use crate::core::Axis; use crate::core::CycleDirection; use crate::core::DefaultLayout; use crate::core::Layout; @@ -26,10 +27,14 @@ use std::num::NonZeroUsize; pub struct Workspace { pub containers: Ring, pub layout: Layout, + pub layout_flip: Option, pub workspace_padding: Option, pub container_padding: Option, + pub tile: bool, #[serde(skip)] pub globals: WorkspaceGlobals, + #[serde(skip_serializing_if = "Option::is_none")] + pub preselected_container_idx: Option, } impl_ring_elements!(Workspace, Container); @@ -41,7 +46,10 @@ impl Default for Workspace { workspace_padding: None, container_padding: None, layout: Layout::Default(DefaultLayout::BSP), + layout_flip: None, globals: Default::default(), + tile: true, + preselected_container_idx: None, } } } @@ -69,6 +77,22 @@ impl Workspace { self.focus_container(j); } + /// Reserves the index `insertion_idx` for the next window: an empty container is inserted + /// there, which the layout gives a tile, and [`Workspace::new_container_for_window`] puts the + /// next window in it + pub fn preselect_container_idx(&mut self, insertion_idx: usize) { + self.preselected_container_idx = Some(insertion_idx); + self.insert_container_at_idx(insertion_idx, Container::preselect()); + } + + /// Removes the container a preselection reserved, if there is one + pub fn cancel_preselect(&mut self) { + if let Some(idx) = self.preselected_container_idx { + self.containers_mut().remove_respecting_locks(idx); + self.preselected_container_idx = None; + } + } + /// Returns the index of the container whose tile is on the `direction` side of the focused /// one in the workspace's layout, or `None` when there is none on that side pub fn new_idx_for_direction(&self, direction: OperationDirection) -> Option { @@ -76,7 +100,7 @@ impl Workspace { direction.destination( self.layout.as_boxed_direction().as_ref(), - None, + self.layout_flip, self.focused_container_idx(), len, None, @@ -124,10 +148,16 @@ impl Workspace { Ok(()) } - /// Puts the window in a container of its own, after the focused container (at the start of an - /// empty workspace), and focuses that container + /// Puts the window in a container of its own, and focuses that container: in the container a + /// preselection reserved, if there is one, and otherwise after the focused container (at the + /// start of an empty workspace) pub fn new_container_for_window(&mut self, window: &Window) -> eyre::Result<()> { - let next_idx = if self.containers().is_empty() { + let next_idx = if let Some(idx) = self.preselected_container_idx { + let next = idx; + self.preselected_container_idx = None; + self.remove_container_by_idx(next); + next + } else if self.containers().is_empty() { 0 } else { self.focused_container_idx() + 1 @@ -291,14 +321,16 @@ impl Workspace { /// Tiles the workspace: asks the layout for a tile per container in the work area, less the /// offset and the workspace padding, and puts every window of each container on its tile. /// - /// Containers that have no windows left are dropped first, and so is a window that is in more - /// than one container. A tile is clamped to the work area, so it cannot bleed onto a - /// neighboring monitor. + /// Containers that have no windows left are dropped first, except the one a preselection + /// reserved, and so is a window that is in more than one container. A tile is clamped to the + /// work area, so it cannot bleed onto a neighboring monitor. A workspace whose tiling is off + /// ([`Workspace::tile`] is false) moves no window. pub fn update(&mut self) -> eyre::Result<()> { // make sure we are never holding on to empty containers self.prune_duplicate_windows()?; let focused_container_idx = self.focused_container_idx(); - self.containers_mut().retain(|c| !c.windows().is_empty()); + self.containers_mut() + .retain(|c| c.is_preselect() || !c.windows().is_empty()); // make sure we are never focused on a no longer existent final empty container if focused_container_idx >= self.containers().len() { @@ -331,63 +363,66 @@ impl Workspace { adjusted_work_area.add_padding(workspace_padding); - // the backend batches the commands this produces and sends them together - let result = (|| -> eyre::Result<()> { - if !self.containers().is_empty() { - let mut layouts = self.layout.as_boxed_arrangement().calculate( - &adjusted_work_area, - NonZeroUsize::new(self.containers().len()).ok_or_eyre( - "there must be at least one container to calculate a workspace layout", - )?, - Some(container_padding), - None, - &[], - self.focused_container_idx(), - None, - &[], - ); + if self.tile { + // the backend batches the commands this produces and sends them together + let result = (|| -> eyre::Result<()> { + if !self.containers().is_empty() { + let mut layouts = self.layout.as_boxed_arrangement().calculate( + &adjusted_work_area, + NonZeroUsize::new(self.containers().len()).ok_or_eyre( + "there must be at least one container to calculate a workspace layout", + )?, + Some(container_padding), + self.layout_flip, + &[], + self.focused_container_idx(), + None, + &[], + ); - let containers = self.containers_mut(); + let containers = self.containers_mut(); - for (i, container) in containers.iter_mut().enumerate() { - if let Some(layout) = layouts.get_mut(i) { - // Every tile is clamped to the work area so it cannot bleed onto an - // adjacent monitor. + for (i, container) in containers.iter_mut().enumerate() { + if let Some(layout) = layouts.get_mut(i) { + // Every tile is clamped to the work area so it cannot bleed onto an + // adjacent monitor. - // horizontal - if layout.left < adjusted_work_area.left { - layout.right -= adjusted_work_area.left - layout.left; - layout.left = adjusted_work_area.left; - } - let max_right_edge = adjusted_work_area.left + adjusted_work_area.right; - let current_right_edge = layout.left + layout.right; - if current_right_edge > max_right_edge { - layout.right = max_right_edge - layout.left; - } + // horizontal + if layout.left < adjusted_work_area.left { + layout.right -= adjusted_work_area.left - layout.left; + layout.left = adjusted_work_area.left; + } + let max_right_edge = adjusted_work_area.left + adjusted_work_area.right; + let current_right_edge = layout.left + layout.right; + if current_right_edge > max_right_edge { + layout.right = max_right_edge - layout.left; + } - // vertical - if layout.top < adjusted_work_area.top { - layout.bottom -= adjusted_work_area.top - layout.top; - layout.top = adjusted_work_area.top; - } - let max_bottom_edge = adjusted_work_area.top + adjusted_work_area.bottom; - let current_bottom_edge = layout.top + layout.bottom; - if current_bottom_edge > max_bottom_edge { - layout.bottom = max_bottom_edge - layout.top; - } + // vertical + if layout.top < adjusted_work_area.top { + layout.bottom -= adjusted_work_area.top - layout.top; + layout.top = adjusted_work_area.top; + } + let max_bottom_edge = + adjusted_work_area.top + adjusted_work_area.bottom; + let current_bottom_edge = layout.top + layout.bottom; + if current_bottom_edge > max_bottom_edge { + layout.bottom = max_bottom_edge - layout.top; + } - for window in container.windows_mut() { - if let Err(error) = window.set_position(layout) { - tracing::warn!("failed to set window position: {error}") + for window in container.windows_mut() { + if let Err(error) = window.set_position(layout) { + tracing::warn!("failed to set window position: {error}") + } } } } } - } - Ok(()) - })(); + Ok(()) + })(); - result?; + result?; + } Ok(()) } diff --git a/komorebi/tests/layouts.rs b/komorebi/tests/layouts.rs new file mode 100644 index 00000000..7553a123 --- /dev/null +++ b/komorebi/tests/layouts.rs @@ -0,0 +1,322 @@ +//! Changing, cycling and flipping the layout of the focused workspace, turning its tiling off and +//! on, and preselecting the place of the next window, against an in-memory desktop: the messages +//! go in through [`WindowManager::process_command`], and what comes out is where the backend is +//! asked to put each window. +//! +//! Window ids are in the 21_000s. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::Axis; +use komorebi::core::CycleDirection; +use komorebi::core::DefaultLayout; +use komorebi::core::OperationDirection; +use komorebi::core::Rect; +use komorebi::core::SocketMessage; +use komorebi::core::StateQuery; +use komorebi::window::Window; +use komorebi::window_manager::WindowManager; +use komorebi::window_manager_event::KWinNotification; +use komorebi::window_manager_event::SystemNotification; +use komorebi::window_manager_event::WindowManagerEvent; +use std::sync::Arc; + +fn added(id: u32) -> WindowManagerEvent { + WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + Window::from(id), + ) +} + +/// A window manager over a fake desktop with one 1920x1080 monitor, and a window for each of +/// `ids`, opened in that order. The last one has the focus. +fn desktop_with(ids: &[u32]) -> (Arc, WindowManager) { + let fake = Arc::new(FakeBackend::with_one_monitor()); + let (_sender, receiver) = crossbeam_channel::bounded(1); + // unix socket paths are limited to ~104 bytes on macOS, which the temp dir alone can exceed + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let socket = std::path::PathBuf::from(format!( + "/tmp/kml-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + )); + + let wm = with_backend(fake.clone(), || { + let mut wm = WindowManager::new(receiver, Some(socket)).unwrap(); + wm.init().unwrap(); + for &id in ids { + open(&fake, &mut wm, id); + } + wm + }); + + (fake, wm) +} + +fn open(fake: &Arc, wm: &mut WindowManager, id: u32) { + fake.open_window(id, "foot", "foot", &format!("terminal {id}")); + with_backend(fake.clone(), || wm.process_event(added(id))).unwrap(); +} + +fn send(fake: &Arc, wm: &mut WindowManager, message: SocketMessage) { + with_backend(fake.clone(), || wm.process_command(message, Vec::new())).unwrap(); +} + +fn layout(fake: &Arc, wm: &mut WindowManager) -> String { + let mut reply = Vec::new(); + with_backend(fake.clone(), || { + wm.process_command( + SocketMessage::Query(StateQuery::FocusedWorkspaceLayout), + &mut reply, + ) + }) + .unwrap(); + String::from_utf8(reply).unwrap() +} + +fn rects(fake: &FakeBackend, ids: &[u32]) -> Vec { + ids.iter().map(|&id| fake.rect_of(id).unwrap()).collect() +} + +/// The window of each container of the focused workspace, in order (0 for an empty container) +fn order(wm: &WindowManager) -> Vec { + wm.focused_workspace() + .unwrap() + .containers() + .iter() + .map(|container| container.focused_window().map_or(0, |window| window.id)) + .collect() +} + +#[test] +fn change_layout_tiles_the_workspace_again_in_the_layout_it_names() { + let ids = [21_001, 21_002, 21_003]; + let (fake, mut wm) = desktop_with(&ids); + assert_eq!(layout(&fake, &mut wm), "BSP"); + + send( + &fake, + &mut wm, + SocketMessage::ChangeLayout(DefaultLayout::Columns), + ); + + assert_eq!(layout(&fake, &mut wm), "Columns"); + let tiles = rects(&fake, &ids); + assert!( + tiles + .iter() + .all(|tile| tile.top == tiles[0].top && tile.bottom == tiles[0].bottom), + "columns are as high as each other: {tiles:?}" + ); + assert!( + tiles[0].left < tiles[1].left && tiles[1].left < tiles[2].left, + "the columns are in the order of the containers: {tiles:?}" + ); +} + +#[test] +fn cycle_layout_goes_through_the_layouts_in_order_and_wraps_around() { + let (fake, mut wm) = desktop_with(&[21_011]); + + send( + &fake, + &mut wm, + SocketMessage::CycleLayout(CycleDirection::Next), + ); + assert_eq!(layout(&fake, &mut wm), "Columns"); + + send( + &fake, + &mut wm, + SocketMessage::CycleLayout(CycleDirection::Previous), + ); + assert_eq!(layout(&fake, &mut wm), "BSP"); + + let mut seen = vec![]; + for _ in 0..9 { + send( + &fake, + &mut wm, + SocketMessage::CycleLayout(CycleDirection::Next), + ); + seen.push(layout(&fake, &mut wm)); + } + assert_eq!( + seen, + [ + "Columns", + "Rows", + "VerticalStack", + "HorizontalStack", + "UltrawideVerticalStack", + "Grid", + "RightMainVerticalStack", + "Scrolling", + "BSP", + ], + "the one after the last is the first" + ); + + // komorebi-layouts leaves the scrolling layout out going backwards + send( + &fake, + &mut wm, + SocketMessage::CycleLayout(CycleDirection::Previous), + ); + assert_eq!(layout(&fake, &mut wm), "RightMainVerticalStack"); +} + +#[test] +fn flip_layout_mirrors_the_tiles_and_the_same_flip_again_puts_them_back() { + let ids = [21_021, 21_022]; + let (fake, mut wm) = desktop_with(&ids); + let before = rects(&fake, &ids); + assert!(before[0].left < before[1].left); + + send(&fake, &mut wm, SocketMessage::FlipLayout(Axis::Horizontal)); + + let flipped = rects(&fake, &ids); + assert_eq!( + flipped[0].left, before[1].left, + "the first window has the right tile" + ); + assert_eq!( + flipped[1].left, before[0].left, + "the second window has the left tile" + ); + + send(&fake, &mut wm, SocketMessage::FlipLayout(Axis::Horizontal)); + + assert_eq!(rects(&fake, &ids), before); +} + +#[test] +fn with_tiling_off_no_window_is_moved_until_it_is_back_on() { + let ids = [21_031, 21_032]; + let (fake, mut wm) = desktop_with(&ids); + let tiles = rects(&fake, &ids); + + send(&fake, &mut wm, SocketMessage::ToggleTiling); + assert_eq!(layout(&fake, &mut wm), "None"); + + let by_hand = Rect { + left: 300, + top: 300, + right: 400, + bottom: 400, + }; + fake.move_window_by_user(21_031, by_hand); + open(&fake, &mut wm, 21_033); + send(&fake, &mut wm, SocketMessage::Retile); + + assert_eq!( + fake.rect_of(21_031), + Some(by_hand), + "moved back by the retile" + ); + assert_eq!( + fake.rect_of(21_033).map(|rect| (rect.right, rect.bottom)), + Some((800, 600)), + "the new window keeps the size it opened with" + ); + assert_eq!( + order(&wm), + [21_031, 21_032, 21_033], + "it is in a container all the same" + ); + + send(&fake, &mut wm, SocketMessage::ToggleTiling); + + assert_eq!(layout(&fake, &mut wm), "BSP"); + assert_ne!(fake.rect_of(21_031), Some(by_hand)); + assert_ne!( + rects(&fake, &ids), + tiles, + "three windows share the work area now" + ); +} + +#[test] +fn preselect_reserves_a_tile_that_the_next_window_takes() { + let ids = [21_041, 21_042]; + let (fake, mut wm) = desktop_with(&ids); + let before = rects(&fake, &ids); + + send( + &fake, + &mut wm, + SocketMessage::PreselectDirection(OperationDirection::Left), + ); + + assert_eq!( + order(&wm), + [21_041, 0, 21_042], + "an empty container holds the place" + ); + assert_ne!( + fake.rect_of(21_042), + Some(before[1]), + "the reserved tile takes room" + ); + + open(&fake, &mut wm, 21_043); + + assert_eq!(order(&wm), [21_041, 21_043, 21_042]); + assert_eq!( + wm.focused_workspace().unwrap().preselected_container_idx, + None + ); +} + +#[test] +fn cancel_preselect_gives_the_reserved_tile_back() { + let ids = [21_051, 21_052]; + let (fake, mut wm) = desktop_with(&ids); + let before = rects(&fake, &ids); + + send( + &fake, + &mut wm, + SocketMessage::PreselectDirection(OperationDirection::Left), + ); + send(&fake, &mut wm, SocketMessage::CancelPreselect); + + assert_eq!(order(&wm), ids); + assert_eq!(rects(&fake, &ids), before); +} + +#[test] +fn a_second_preselect_is_ignored_while_a_place_is_reserved() { + let (fake, mut wm) = desktop_with(&[21_061, 21_062]); + + send( + &fake, + &mut wm, + SocketMessage::PreselectDirection(OperationDirection::Left), + ); + send( + &fake, + &mut wm, + SocketMessage::PreselectDirection(OperationDirection::Left), + ); + + assert_eq!(order(&wm), [21_061, 0, 21_062]); +} + +#[test] +fn the_grid_layout_has_no_preselect() { + let (fake, mut wm) = desktop_with(&[21_071, 21_072]); + send( + &fake, + &mut wm, + SocketMessage::ChangeLayout(DefaultLayout::Grid), + ); + + send( + &fake, + &mut wm, + SocketMessage::PreselectDirection(OperationDirection::Left), + ); + + assert_eq!(order(&wm), [21_071, 21_072]); +} diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index 78892efe..c1c970b9 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -7,7 +7,10 @@ //! //! `focus` and `cycle-focus` give the focus to another window of the focused workspace, and //! `mouse-follows-focus` and `toggle-mouse-follows-focus` set whether the pointer goes with it. -//! `move` and `cycle-move` move the focused window to another tile. +//! `move` and `cycle-move` move the focused window to another tile. `change-layout`, +//! `cycle-layout` and `flip-layout` change the layout of the focused workspace, `toggle-tiling` +//! turns its tiling off and on, and `preselect-direction` and `cancel-preselect` reserve the +//! place of the next window and give it up. //! //! `subscribe-socket` and `unsubscribe-socket` add and remove a subscriber: a program that listens //! on a socket in the daemon's data directory, to which the daemon sends a notification after each @@ -24,7 +27,9 @@ use clap::Parser; use clap::ValueEnum; use color_eyre::eyre; use fs_tail::TailedFile; +use komorebi_client::Axis; use komorebi_client::CycleDirection; +use komorebi_client::DefaultLayout; use komorebi_client::OperationDirection; use komorebi_client::SocketMessage; use komorebi_client::StateQuery; @@ -72,8 +77,13 @@ macro_rules! gen_enum_subcommand_args { gen_enum_subcommand_args! { Focus: OperationDirection, Move: OperationDirection, + PreselectDirection: OperationDirection, CycleFocus: CycleDirection, CycleMove: CycleDirection, + FlipLayout: Axis, + ChangeLayout: DefaultLayout, + CycleLayout: CycleDirection, + // WatchConfiguration: BooleanState, MouseFollowsFocus: BooleanState, Query: StateQuery, } @@ -165,14 +175,34 @@ enum SubCommand { /// Move the focused window in the specified direction #[clap(arg_required_else_help = true)] Move(Move), + /// Preselect the specified direction for the next window to be spawned on supported layouts + #[clap(arg_required_else_help = true)] + PreselectDirection(PreselectDirection), + /// Cancel a workspace preselect set by the preselect-direction command, if one exists + CancelPreselect, /// Change focus to the window in the specified cycle direction #[clap(arg_required_else_help = true)] CycleFocus(CycleFocus), /// Move the focused window in the specified cycle direction #[clap(arg_required_else_help = true)] CycleMove(CycleMove), + /// Set the layout on the focused workspace + #[clap(arg_required_else_help = true)] + ChangeLayout(ChangeLayout), + /// Cycle between available layouts + #[clap(arg_required_else_help = true)] + CycleLayout(CycleLayout), + // /// Load a custom layout from file for the focused workspace + // #[clap(hide = true)] + // #[clap(arg_required_else_help = true)] + // LoadCustomLayout(LoadCustomLayout), + /// Flip the layout on the focused workspace + #[clap(arg_required_else_help = true)] + FlipLayout(FlipLayout), /// Force the retiling of all managed windows Retile, + /// Toggle window tiling on the focused workspace + ToggleTiling, /// Enable or disable mouse follows focus on all workspaces #[clap(arg_required_else_help = true)] MouseFollowsFocus(MouseFollowsFocus), @@ -301,15 +331,33 @@ fn main() -> eyre::Result<()> { SubCommand::Move(args) => { send_message(&SocketMessage::MoveWindow(args.operation_direction))?; } + SubCommand::PreselectDirection(args) => { + send_message(&SocketMessage::PreselectDirection(args.operation_direction))?; + } + SubCommand::CancelPreselect => { + send_message(&SocketMessage::CancelPreselect)?; + } SubCommand::CycleFocus(args) => { send_message(&SocketMessage::CycleFocusWindow(args.cycle_direction))?; } SubCommand::CycleMove(args) => { send_message(&SocketMessage::CycleMoveWindow(args.cycle_direction))?; } + SubCommand::ChangeLayout(args) => { + send_message(&SocketMessage::ChangeLayout(args.default_layout))?; + } + SubCommand::CycleLayout(args) => { + send_message(&SocketMessage::CycleLayout(args.cycle_direction))?; + } + SubCommand::FlipLayout(args) => { + send_message(&SocketMessage::FlipLayout(args.axis))?; + } SubCommand::Retile => { send_message(&SocketMessage::Retile)?; } + SubCommand::ToggleTiling => { + send_message(&SocketMessage::ToggleTiling)?; + } SubCommand::State => { print_query(&SocketMessage::State); } diff --git a/komorebic/tests/cli.rs b/komorebic/tests/cli.rs index 41ae4097..b052d92a 100644 --- a/komorebic/tests/cli.rs +++ b/komorebic/tests/cli.rs @@ -190,6 +190,32 @@ fn move_and_cycle_move_send_the_direction_they_are_given() { ); } +#[test] +fn the_layout_subcommands_send_the_layout_axis_or_direction_they_are_given() { + sends( + &["change-layout", "vertical-stack"], + r#"{"type":"ChangeLayout","content":"VerticalStack"}"#, + ); + sends( + &["change-layout", "bsp"], + r#"{"type":"ChangeLayout","content":"BSP"}"#, + ); + sends( + &["cycle-layout", "next"], + r#"{"type":"CycleLayout","content":"Next"}"#, + ); + sends( + &["flip-layout", "horizontal-and-vertical"], + r#"{"type":"FlipLayout","content":"HorizontalAndVertical"}"#, + ); + sends(&["toggle-tiling"], r#"{"type":"ToggleTiling"}"#); + sends( + &["preselect-direction", "up"], + r#"{"type":"PreselectDirection","content":"Up"}"#, + ); + sends(&["cancel-preselect"], r#"{"type":"CancelPreselect"}"#); +} + #[test] fn mouse_follows_focus_is_set_with_enable_and_disable_or_toggled() { sends( @@ -245,15 +271,20 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { assert_eq!( pages, [ + "cancel-preselect.md", + "change-layout.md", "completions.md", "cycle-focus.md", + "cycle-layout.md", "cycle-move.md", "data-directory.md", + "flip-layout.md", "focus.md", "log.md", "monitor-information.md", "mouse-follows-focus.md", "move.md", + "preselect-direction.md", "query.md", "retile.md", "start.md", @@ -261,6 +292,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "stop.md", "subscribe-socket.md", "toggle-mouse-follows-focus.md", + "toggle-tiling.md", "unsubscribe-socket.md", ] ); diff --git a/nix/tests/vm/lib/komorebi_vm/kde.py b/nix/tests/vm/lib/komorebi_vm/kde.py index e07c3e32..17960b28 100644 --- a/nix/tests/vm/lib/komorebi_vm/kde.py +++ b/nix/tests/vm/lib/komorebi_vm/kde.py @@ -288,6 +288,15 @@ class Kde: containers = self._workspace(workspace)["containers"]["elements"] return sum(len(container["windows"]["elements"]) for container in containers) + def managed_all(self) -> int: + """The number of windows the daemon has, in the containers of every workspace it has.""" + return sum( + len(container["windows"]["elements"]) + for monitor in self.state()["monitors"]["elements"] + for workspace in monitor["workspaces"]["elements"] + for container in workspace["containers"]["elements"] + ) + def monitor_bridge(self, path: str) -> None: """Starts recording the bridge's traffic on the session bus to `path`, in the background. diff --git a/nix/tests/vm/lib/tests/test_kde.py b/nix/tests/vm/lib/tests/test_kde.py index 21dbe48a..cc684f7e 100644 --- a/nix/tests/vm/lib/tests/test_kde.py +++ b/nix/tests/vm/lib/tests/test_kde.py @@ -381,6 +381,22 @@ def test_managed_counts_the_windows_of_every_container_of_the_workspace( assert kde.managed(1) == 1 +def test_managed_all_counts_the_windows_of_every_workspace_of_every_monitor( + machine: FakeMachine, +) -> None: + first = {"id": 1, "workspaces": {"elements": [workspace(container("a", "b"))], "focused": 0}} + second = { + "id": 2, + "workspaces": {"elements": [workspace(container("c")), workspace()], "focused": 0}, + } + machine.reply( + "komorebic state", + output=json.dumps({"monitors": {"elements": [first, second], "focused": 0}}), + ) + + assert Kde(machine, "main").managed_all() == 3 + + def test_managed_is_zero_for_a_workspace_without_containers(machine: FakeMachine) -> None: machine.reply("komorebic state", output=a_state(workspace())) diff --git a/nix/tests/vm/suites/commands/default.nix b/nix/tests/vm/suites/commands/default.nix new file mode 100644 index 00000000..b51bc717 --- /dev/null +++ b/nix/tests/vm/suites/commands/default.nix @@ -0,0 +1,5 @@ +{ + tier = "fast"; + kind = "headless"; + timeoutMinutes = 15; +} diff --git a/nix/tests/vm/suites/commands/test.py b/nix/tests/vm/suites/commands/test.py new file mode 100644 index 00000000..2f6eac41 --- /dev/null +++ b/nix/tests/vm/suites/commands/test.py @@ -0,0 +1,216 @@ +# Every layout, and a sweep of komorebic's commands, against a real headless KWin with two outputs. +# +# After each command the same things have to be true: komorebi is alive, it can still say what it +# knows (`komorebic state`), and every window it has is a window KWin has. After each layout, the +# windows are inside the outputs and do not lie on each other. A command is also expected to be +# understood (exit status 0). The Rust tests check what each command does against the fake; this +# suite checks that none of them breaks the daemon in a real session, and that what the daemon +# asks KWin for is what KWin carries out. Every command `komorebic` has is in the sweep. +import shlex + +from komorebi_vm import Checks, Kde, daemon + +W, H = 1920, 1080 +OUTPUTS = 2 +# foot sizes itself to whole cells of its font, so its frame can be short of the one asked for +CELL = 24 +LAYOUTS = [ + "bsp", + "columns", + "rows", + "vertical-stack", + "horizontal-stack", + "ultrawide-vertical-stack", + "grid", + "right-main-vertical-stack", + "scrolling", +] + +start_all() +machine.wait_for_unit("multi-user.target") +machine.wait_for_unit("user@1000.service") + +checks = Checks() +kde = Kde.headless(machine, "main", size=(W, H), outputs=OUTPUTS, scale=SCALE) + + +def live_windows() -> list[str]: + """The ids of the windows KWin has: every client of this suite is a foot.""" + return kde.run("kdotool search --class foot")[1].split() + + +def healthy() -> int: + """0 when komorebi is running, answers `komorebic state` and has every window KWin has. + + Otherwise the reason: 1 not running, 2 no state, 3 not the windows KWin has. + """ + if not kde.komorebi_running(): + return 1 + status, _ = kde.run("komorebic state") + if status != 0: + return 2 + if kde.managed_all() != len(live_windows()): + return 3 + return 0 + + +def outside_or_overlapping() -> list[str]: + """The windows that are outside the outputs, or on another window (2 pixels are allowed).""" + rects = {wid: kde.rect(wid) for wid in live_windows()} + bad = [] + for wid, r in rects.items(): + if r.x < -1 or r.y < -1 or r.x + r.w > W * OUTPUTS + 1 or r.y + r.h > H + 1: + bad.append(f"{kde.title(wid)} outside at {r}") + ids = list(rects) + for i, a in enumerate(ids): + for b in ids[i + 1 :]: + ra, rb = rects[a], rects[b] + if ( + ra.x < rb.x + rb.w - 2 + and rb.x < ra.x + ra.w - 2 + and ra.y < rb.y + rb.h - 2 + and rb.y < ra.y + ra.h - 2 + ): + bad.append(f"{kde.title(a)} on {kde.title(b)}") + return bad + + +def open_window(n: int) -> None: + title = f"win{n}" + kde.spawn(f"foot -T {title} sleep 3000") + kde.wait_for(lambda: kde.wid(title) != "", timeout=30) + + +def close_window(n: int) -> None: + title = f"win{n}" + kde.close(kde.wid(title)) + kde.wait_for(lambda: kde.wid(title) == "", timeout=10) + + +def where_asked(wid: str) -> str | None: + """None when KWin has the window where the daemon last asked, else what is wrong.""" + title = kde.title(wid) + want, have = daemon.asked(kde.komorebi_log(), title), kde.rect(wid) + if want is None: + return f"{title}: the daemon never asked for a frame" + if (have.x, have.y) != (want.x, want.y) or not ( + 0 <= want.w - have.w < CELL and 0 <= want.h - have.h < CELL + ): + return f"{title}: asked {want}, has {have}" + return None + + +print("== 1. start, and five windows on the first output") +kde.start_komorebi() +checks.check( + "the daemon answers komorebic, with both outputs", + kde.wait_for( + lambda: kde.run("komorebic monitor-information")[0] == 0 + and len(kde.state()["monitors"]["elements"]) == OUTPUTS, + timeout=30, + ), +) +for n in range(1, 6): + open_window(n) +checks.check( + "five windows, and komorebi has all five", + kde.wait_for(lambda: kde.managed() == 5 and healthy() == 0, timeout=20), +) + +print("== 2. every layout, with five, three, two and one windows") +for layout in LAYOUTS: + kde.komorebic("change-layout", layout) + verdict = [] + for keep in (5, 3, 2, 1): + while kde.managed() > keep: + close_window(kde.managed()) + kde.wait_for(lambda: healthy() == 0, timeout=10) + # the daemon retiles on the close; give KWin the time to carry it out + kde.wait_for(lambda: all(where_asked(w) is None for w in live_windows()), timeout=10) + # the scrolling layout is a strip whose columns can be past the edge of the output + if layout != "scrolling": + verdict += [f"{keep}w: {bad}" for bad in outside_or_overlapping()] + reason = healthy() + if reason: + verdict.append(f"{keep}w: not healthy ({reason})") + checks.check( + f"{layout}: the windows are in their outputs and do not overlap, and komorebi is healthy" + + "".join(f"\n {v}" for v in verdict), + verdict == [], + ) + for n in range(kde.managed() + 1, 6): + open_window(n) + kde.wait_for(lambda: kde.managed() == 5, timeout=20) +kde.komorebic("change-layout", "bsp") + +print("== 3. a sweep of commands (each must be understood, and leave komorebi healthy)") +SWEEP = """ +focus left +focus right +focus up +focus down +move left +move right +move up +move down +cycle-focus next +cycle-focus previous +cycle-move next +cycle-move previous +retile +flip-layout horizontal +flip-layout vertical +flip-layout horizontal-and-vertical +flip-layout horizontal-and-vertical +cycle-layout next +cycle-layout previous +change-layout columns +change-layout bsp +preselect-direction left +cancel-preselect +toggle-tiling +toggle-tiling +mouse-follows-focus enable +mouse-follows-focus disable +toggle-mouse-follows-focus +toggle-mouse-follows-focus +retile +""" +for line in SWEEP.strip().splitlines(): + status, out = kde.run(shlex.join(["komorebic", *line.split()])) + if status != 0: + checks.bad(f"komorebic {line}: exit {status}: {' '.join(out.splitlines()[:2])}") + continue + if not kde.wait_for(lambda: healthy() == 0, timeout=5): + checks.bad(f"komorebic {line}: komorebi is not healthy afterwards ({healthy()})") + continue + checks.ok(f"komorebic {line}") + +print("== 4. what komorebic only prints") +for args in ["state", "monitor-information", "data-directory"]: + checks.check(f"komorebic {args}", kde.run(f"komorebic {args}")[0] == 0) +for query in [ + "focused-monitor-index", + "focused-workspace-index", + "focused-container-index", + "focused-window-index", + "focused-workspace-layout", + "focused-container-kind", + "version", +]: + status, out = kde.run(f"komorebic query {query}") + checks.check(f"query {query} answers", status == 0 and out.strip() != "") + +print("== 5. still all there") +checks.check("komorebi has every window KWin has, and answers", healthy() == 0) +for wid in live_windows(): + print(f" {kde.title(wid)}: {kde.rect(wid)}") +mismatch = [m for m in (where_asked(w) for w in live_windows()) if m is not None] +for m in mismatch: + print(f" {m}") +checks.check("every window is where komorebi last asked for it", mismatch == []) +kde.komorebic("stop") +checks.check("a clean stop", kde.wait_for(lambda: not kde.komorebi_running())) + +kde.stop() +checks.finish()