feat(wm): layouts

Every workspace started in BSP and stayed there. `komorebic change-layout` gives
the focused workspace one of the layouts of komorebi-layouts, `cycle-layout
next|previous` steps through them, and `flip-layout` mirrors the tiles along an
axis, on top of the flip there is. A flip mirrors the tiles, not the order of
the containers, and the directions of focus and move are flipped with it, so
`focus left` still goes to the tile on the left.

`toggle-tiling` turns the tiling of the focused workspace off and on: while it
is off, no window of the workspace is moved, and the windows that open are taken
in and tiled once it is on again.

`preselect-direction` reserves the place on one side of the focused window for
the next window: an empty container holds it, the layout gives it a tile, and
the next window takes it. `cancel-preselect` gives it back.

A new VM suite, vm-commands, after the spike's m8 script, runs every layout with
five, three, two and one windows against a real KWin with two outputs, and then
every komorebic command there is, checking after each that the daemon is alive,
answers, and has every window KWin has, and that KWin has the windows where the
daemon asked. Every command that comes later adds its line to that sweep.
This commit is contained in:
LGUG2Z committed 2026-10-28 07:20:37 -07:00
1 parent 1bbb534844
commit 41c32aad8e
25 files changed
+1224 -70

No files matched your search

+5 -3
View File
@@ -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
+10
View File
@@ -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
```
+23
View File
@@ -0,0 +1,23 @@
```text
Set the layout on the focused workspace
Usage: change-layout <DEFAULT_LAYOUT>
Arguments:
<DEFAULT_LAYOUT>
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')
```
+14
View File
@@ -0,0 +1,14 @@
```text
Cycle between available layouts
Usage: cycle-layout <CYCLE_DIRECTION>
Arguments:
<CYCLE_DIRECTION>
[possible values: previous, next]
Options:
-h, --help
Print help
```
+17
View File
@@ -0,0 +1,17 @@
```text
Flip the layout on the focused workspace
Usage: flip-layout <AXIS>
Arguments:
<AXIS>
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')
```
+14
View File
@@ -0,0 +1,14 @@
```text
Preselect the specified direction for the next window to be spawned on supported layouts
Usage: preselect-direction <OPERATION_DIRECTION>
Arguments:
<OPERATION_DIRECTION>
[possible values: left, right, up, down]
Options:
-h, --help
Print help
```
+10
View File
@@ -0,0 +1,10 @@
```text
Toggle window tiling on the focused workspace
Usage: toggle-tiling
Options:
-h, --help
Print help
```
+33 -5
View File
@@ -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
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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.
+80
View File
@@ -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.
+15
View File
@@ -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 {
+10
View File
@@ -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,
+8 -3
View File
@@ -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<Layout> {
self.focused_workspace()
.map(|workspace| workspace.layout.clone())
self.focused_workspace().and_then(|workspace| {
if workspace.tile {
Some(workspace.layout.clone())
} else {
None
}
})
}
}
+37
View File
@@ -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,
+3
View File
@@ -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::<VecDeque<_>>();
ws.focus(monitor.workspaces.focused_idx());
+201
View File
@@ -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<usize> {
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()));
+90 -55
View File
@@ -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<Container>,
pub layout: Layout,
pub layout_flip: Option<Axis>,
pub workspace_padding: Option<i32>,
pub container_padding: Option<i32>,
pub tile: bool,
#[serde(skip)]
pub globals: WorkspaceGlobals,
#[serde(skip_serializing_if = "Option::is_none")]
pub preselected_container_idx: Option<usize>,
}
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<usize> {
@@ -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(())
}
+322
View File
@@ -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<FakeBackend>, 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<FakeBackend>, 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<FakeBackend>, wm: &mut WindowManager, message: SocketMessage) {
with_backend(fake.clone(), || wm.process_command(message, Vec::new())).unwrap();
}
fn layout(fake: &Arc<FakeBackend>, 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<Rect> {
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<u32> {
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]);
}
+49 -1
View File
@@ -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);
}
+32
View File
@@ -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",
]
);
+9
View File
@@ -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.
+16
View File
@@ -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()))
+5
View File
@@ -0,0 +1,5 @@
{
tier = "fast";
kind = "headless";
timeoutMinutes = 15;
}
+216
View File
@@ -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()