From 331db681e27b0a4d26cd432fc94bba20d0ecf8c3 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 19:03:55 -0700 Subject: [PATCH] feat(wm): stack out of sight on a parking desktop A container has one tile, so the windows of a stack that are not in front have to be out of sight. KWin has nothing a script can set to hide a window except a virtual desktop nobody looks at, which the first spike measured to cost no focus change where minimizing a window cost two. Backend::hide takes a window out of sight and Backend::restore brings it back; the KWin backend parks the window on a desktop named komorebi-park, which the table keeps out of the workspaces, and remembers where each hidden window came from so that it is still on its workspace. KWin saves a desktop in kwinrc as soon as it is made, so komorebi removes the parking desktop when it stops, after the windows are back, and the first snapshot of the next run finds one a killed run left, sends its windows home and removes it. The windows komorebi hid are recorded, so that a minimize the compositor reports for one is not mistaken for the user's. komorebic stack, unstack, stack-all and unstack-all stack and unstack windows. The window container behavior and the float override decide whether a window that opens joins the focused container or floats, for every workspace or for one. vm-stacks stacks windows in a headless KWin, shows a workspace and goes back, stops, and kills the daemon with a window parked. --- README.md | 13 +- docs/cli/stack-all.md | 10 + docs/cli/stack.md | 14 + docs/cli/toggle-float-override.md | 10 + docs/cli/toggle-window-container-behaviour.md | 10 + docs/cli/toggle-workspace-float-override.md | 12 + ...le-workspace-window-container-behaviour.md | 11 + docs/cli/unstack-all.md | 10 + docs/cli/unstack.md | 10 + docs/dev/ARCHITECTURE.md | 145 ++++- docs/dev/DESIGN.md | 37 ++ docs/dev/PLATFORM.md | 59 +- docs/dev/vm-tests.md | 15 +- docs/index.md | 2 + docs/usage/floating-windows.md | 17 + docs/usage/running-komorebi.md | 7 +- docs/usage/stacking-windows.md | 81 +++ komorebi-client/src/lib.rs | 4 +- komorebi/src/backend/fake.rs | 64 ++- komorebi/src/backend/mod.rs | 35 ++ komorebi/src/container.rs | 133 +++++ komorebi/src/core/mod.rs | 9 + komorebi/src/kwin/mod.rs | 184 ++++++- komorebi/src/kwin/state.rs | 503 +++++++++++++++++- komorebi/src/lib.rs | 8 +- komorebi/src/main.rs | 10 +- komorebi/src/process_command.rs | 85 ++- komorebi/src/process_event.rs | 53 +- komorebi/src/state.rs | 20 +- komorebi/src/window.rs | 66 +++ komorebi/src/window_manager.rs | 424 ++++++++++++++- komorebi/src/workspace.rs | 366 ++++++++++++- komorebi/tests/stacks.rs | 481 +++++++++++++++++ komorebi/tests/workspaces.rs | 13 +- komorebic/src/main.rs | 66 ++- komorebic/tests/cli.rs | 55 ++ nix/tests/vm/lib/komorebi_vm/kde.py | 36 ++ nix/tests/vm/lib/tests/test_kde.py | 51 ++ nix/tests/vm/suites/commands/test.py | 12 + nix/tests/vm/suites/stacks/default.nix | 5 + nix/tests/vm/suites/stacks/test.py | 182 +++++++ 41 files changed, 3234 insertions(+), 94 deletions(-) create mode 100644 docs/cli/stack-all.md create mode 100644 docs/cli/stack.md create mode 100644 docs/cli/toggle-float-override.md create mode 100644 docs/cli/toggle-window-container-behaviour.md create mode 100644 docs/cli/toggle-workspace-float-override.md create mode 100644 docs/cli/toggle-workspace-window-container-behaviour.md create mode 100644 docs/cli/unstack-all.md create mode 100644 docs/cli/unstack.md create mode 100644 docs/usage/stacking-windows.md create mode 100644 komorebi/tests/stacks.rs create mode 100644 nix/tests/vm/suites/stacks/default.nix create mode 100644 nix/tests/vm/suites/stacks/test.py diff --git a/README.md b/README.md index 0f402fe3..6e25d65d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ without a checkout of the toolchain. the vocabulary the window manager is written in (`core`), the `Backend` trait through which it reaches a compositor and the in-memory desktop the tests use (`backend`), the backend that reaches KWin through a script it runs and edits - the one setting of KWin's it needs in the user's `kwinrc` (`kwin`), + the one setting of KWin's it needs in the user's `kwinrc` and the parking desktop + it hides the back of a stack on (`kwin`), the model of what it tiles (`monitor`, `workspace`, `container`, `window`), the window manager that keeps the model (`window_manager`), loads it from the backend (`desktop`) and changes it when the compositor reports an event @@ -139,6 +140,12 @@ reports that a window was shown, closed, focused, minimized or restored, and tiles the workspace in front again. A window open at startup goes to the workspace whose desktop it is on. +A container with more than one window is a stack: the layout gives it one tile, +and the window in front is in sight. The others are hidden, which on KWin means +moved to one more virtual desktop, `komorebi-park`, that nobody looks at and that +is not a workspace. The daemon makes it when it first needs it, removes it when +it stops, and removes one that a run that did not stop left behind. + Everything else reaches the daemon as a message on its socket: one JSON `SocketMessage` per line, handled one at a time with the window manager locked. A message that asks a question is answered on the same connection. A @@ -171,6 +178,10 @@ the previous or the last workspace, `workspace-name` names a workspace for At the edge of a monitor, `focus` and `move` cross to the monitor on that side; `focus-monitor`, `move-to-monitor`, `send-to-monitor` and their kind work on the monitors, and `eager-focus` finds the window of an application wherever it is. +`stack` puts the focused window and the one next to it in one stack, `unstack` +takes it out again, and `stack-all` and `unstack-all` do it for a whole +workspace. `toggle-window-container-behaviour` makes a window that opens join +the focused stack, and `toggle-float-override` makes it float. 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/stack-all.md b/docs/cli/stack-all.md new file mode 100644 index 00000000..cf67c6cf --- /dev/null +++ b/docs/cli/stack-all.md @@ -0,0 +1,10 @@ +```text +Stack all windows on the focused workspace + +Usage: stack-all + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/stack.md b/docs/cli/stack.md new file mode 100644 index 00000000..bd145d4f --- /dev/null +++ b/docs/cli/stack.md @@ -0,0 +1,14 @@ +```text +Stack the focused window in the specified direction + +Usage: stack + +Arguments: + + [possible values: left, right, up, down] + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/toggle-float-override.md b/docs/cli/toggle-float-override.md new file mode 100644 index 00000000..d0b7d4cf --- /dev/null +++ b/docs/cli/toggle-float-override.md @@ -0,0 +1,10 @@ +```text +Enable or disable float override, which makes it so every new window opens in floating mode + +Usage: toggle-float-override + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/toggle-window-container-behaviour.md b/docs/cli/toggle-window-container-behaviour.md new file mode 100644 index 00000000..6e4fe4c7 --- /dev/null +++ b/docs/cli/toggle-window-container-behaviour.md @@ -0,0 +1,10 @@ +```text +Toggle the behavior for new windows (stacking or dynamic tiling) + +Usage: toggle-window-container-behaviour + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/toggle-workspace-float-override.md b/docs/cli/toggle-workspace-float-override.md new file mode 100644 index 00000000..c48293aa --- /dev/null +++ b/docs/cli/toggle-workspace-float-override.md @@ -0,0 +1,12 @@ +```text +Enable or disable float override, which makes it so every new window opens in floating mode, for the +currently focused workspace. If there was no override value set for the workspace previously it +takes the opposite of the global value + +Usage: toggle-workspace-float-override + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/toggle-workspace-window-container-behaviour.md b/docs/cli/toggle-workspace-window-container-behaviour.md new file mode 100644 index 00000000..4d0999c9 --- /dev/null +++ b/docs/cli/toggle-workspace-window-container-behaviour.md @@ -0,0 +1,11 @@ +```text +Toggle the behavior for new windows (stacking or dynamic tiling) for currently focused workspace. If +there was no behavior set for the workspace previously it takes the opposite of the global value + +Usage: toggle-workspace-window-container-behaviour + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/unstack-all.md b/docs/cli/unstack-all.md new file mode 100644 index 00000000..fe35e003 --- /dev/null +++ b/docs/cli/unstack-all.md @@ -0,0 +1,10 @@ +```text +Unstack all windows in the focused container + +Usage: unstack-all + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/unstack.md b/docs/cli/unstack.md new file mode 100644 index 00000000..c559ebf5 --- /dev/null +++ b/docs/cli/unstack.md @@ -0,0 +1,10 @@ +```text +Unstack the focused window + +Usage: unstack + +Options: + -h, --help + Print help + +``` diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index d5a8442e..56c35060 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -8,9 +8,9 @@ tested, without one. This page describes that trait, who implements and calls it, where each of its rules is enforced, the model of the desktop the window manager tiles, the backend that reaches KWin, how an event about a window becomes a tile, how workspaces are the compositor's desktops, how -a message on the daemon's socket is answered, how subscribers are notified of both, what the -orders a message gives do, and how the NixOS module runs the daemon. It grows with the code: -each part of the daemon that is added has its place here. +a stack hides its windows, how a message on the daemon's socket is answered, how subscribers +are notified of both, what the orders a message gives do, and how the NixOS module runs the +daemon. It grows with the code: each part of the daemon that is added has its place here. ## Vocabulary @@ -27,6 +27,13 @@ each part of the daemon that is added has its place here. desktop of its own. - **Container**: what a layout places. It gets one tile, and every window in it is put on that tile. +- **Stack**: a container with more than one window. It has one tile, and the window in front + (the container's focused window) is in sight; the others are hidden. +- **Hidden window**: a window of a stack that is not in front, which komorebi has taken out of + sight ([`Backend::hide`]) and recorded in [`HIDDEN_WINDOWS`]. It is alive, not minimized, and + on its workspace. +- **Parking desktop**: the virtual desktop, named `komorebi-park`, that the KWin backend hides + windows on. It is not a workspace. - **Layout**: the arrangement (BSP, columns, rows, ...) that turns a work area and a number of containers into one rectangle, a **tile**, for each container. - **Event**: what the compositor reports about a window: it was shown, destroyed, given @@ -87,9 +94,9 @@ The desktop, as the window manager sees it, is two lists and a pointer: - **The pointer**, a position if the compositor says where it is, and the **focus**, the id of the window that has it. -A command is one of: move and resize a window, focus it, raise it, minimize it or bring it -back, close it, put the pointer in the middle of a rectangle, make desktops for the -workspaces, show a workspace on a monitor, and put a window on a workspace. The workspace a +A command is one of: move and resize a window, focus it, raise it, hide it or restore it, +minimize it or bring it back, close it, put the pointer in the middle of a rectangle, make +desktops for the workspaces, show a workspace on a monitor, and put a window on a workspace. The workspace a window is on, and the one each monitor shows, are queries. The window manager tiles a tree of four levels, each level a [`Ring`]: a list with one @@ -103,8 +110,10 @@ the same thing at every level. own, whether it takes the monitor's window-based offset, its containers, in order, and its floating windows, which are in no container and which the layout does not tile. - A **container** ([`Container`]) has its windows. A new window gets a container of its - own. A container can be locked, which keeps it at its index while others are inserted, - removed or swapped around it. + own, unless the window container behavior says to put it in the focused one. With more than + one window it is a stack: the focused window is in front and the others are hidden. A + container can be locked, which keeps it at its index while others are inserted, removed or + swapped around it. - A **window** ([`Window`]) is a `Copy` handle that carries only the compositor's id. Everything else about the window, and every change to it, goes through the backend. @@ -224,6 +233,54 @@ window put on another desktop by something else (the window menu's "Move to Desk and move come back from KWin as the same reports; the table records them when the command is sent ([`Table::switch_commands`], [`Table::window_workspace_commands`]), so they raise nothing. +## Stacks + +A container has one tile, so when it has more than one window the others have to be out of +sight, and out of the way of the pointer and the focus: the window manager hides them +([`Window::hide`]). Minimizing them would not do. KWin reports a minimize as the user's, and +the windows would be taken for ones the user does not want tiled. What komorebi uses is the one +thing KWin has that hides a window and leaves it alone: a virtual desktop nobody looks at, the +parking desktop. The reasons are in +[DESIGN.md](DESIGN.md#why-are-the-windows-behind-the-front-one-of-a-stack-moved-to-a-parking-desktop). + +A container keeps its front window in sync with what is in sight ([`Container::add_window`], +[`Container::load_focused_window`]): the window that is added or focused is shown, and every +other one is hidden. Operations that change which window is in front, or which container a +window is in, end with that call: stacking and unstacking ([`Workspace::move_window_to_container`], +[`Workspace::new_container_for_focused_window`]), a window that closes +([`Workspace::remove_window`], which brings the next one forward and focuses it), a window that +is floated, and a focus change to a window that is behind ([`Workspace::focus_container_by_window`]). + +[`Window::hide`] puts the window's id in [`HIDDEN_WINDOWS`] before it asks the backend, and +[`Window::restore`] takes it out, so that when the compositor reports something about the window +because of the hide the window manager knows who did it. A `Minimize` for a window in the set is +not the user's and changes nothing. On the way out, [`WindowManager::stop`] and the daemon's +`SIGINT` and `SIGTERM` handler restore every window in the set before the backend shuts down +([`WindowManager::restore_all_windows`]). + +The KWin backend hides a window by putting it on the parking desktop. The table keeps for each +window whether it is parked and the desktop it came from ([`Table::hide_commands`], +[`Table::restore_commands`]): a hidden window is still on its workspace for every query +([`Table::window_workspace`]), a window put on another workspace while it is hidden is hidden +still and comes back there ([`Table::window_workspace_commands`]), and KWin's report that the +window moved to the parking desktop is not a `WorkspaceMove`. The parking desktop is made the +first time it is needed ([`NeedsPark`], `create_park_desktop` in [`kwin`]), by position, so +that it stays after the workspaces' desktops. The table checks that KWin still lists it before it +sends a window there, and asks for a new one when somebody removed it. + +An application that raises a hidden window, or a click on it in the task manager, makes KWin +switch that monitor to the parking desktop. The table notices that a monitor shows it, sends the +monitor back to the desktop it showed before, and raises no `WorkspaceChange`; the window's own +`FocusChange` then brings it to the front of its stack. + +KWin keeps its virtual desktops in `kwinrc` as soon as one is made, so a parking desktop that +komorebi does not remove stays for every later session. The backend removes it in `shutdown`, +after the restoring commands have reached the bridge. A daemon that was killed leaves it, with +windows on it: the first snapshot the next daemon gets lists a desktop with that name, which the +table treats as stale ([`Table::stale_park`]). It is never the parking desktop of the new run. +The snapshot sends the windows on it to the first workspace's desktop, and half a second later +the backend removes it (`sweep_stale_park`). + ## The command socket The daemon takes messages on a Unix socket, `komorebi.sock` in [`DATA_DIR`] @@ -363,6 +420,24 @@ any other, so its events find it, and [`Workspace::update`] leaves it where it i floated window goes is [`WindowManagementBehaviour::toggle_float_placement`]: by default it stays where it is. +`StackWindow` puts the focused window and the window of the container in a direction in one +container ([`WindowManager::add_window_to_container`]): the focused window goes to the front of +that container, or, when the focused container is a stack and the other is not, the other +container's window goes into the stack. `UnstackWindow` takes the front window out of a stack +into a container of its own after it ([`WindowManager::remove_window_from_container`]), `StackAll` +puts every window of the workspace in one container ([`WindowManager::stack_all`]), and +`UnstackAll` takes them out again ([`WindowManager::unstack_all`]). Each ends with the workspace +tiled again, and the window that was in front has the focus. + +A window that opens gets the behavior [`WindowManager::window_management_behaviour`] says for +its workspace: a container of its own (`Create`, the default), or a place in the focused +container (`Append`), and a floating window with the float override on. A workspace's own +[`Workspace::window_container_behaviour`] and [`Workspace::float_override`] win over the window +manager's, and `Append` on an empty workspace is `Create`. `ToggleWindowContainerBehaviour` and +`ToggleFloatOverride` flip the window manager's, and `ToggleWorkspaceWindowContainerBehaviour` and +`ToggleWorkspaceFloatOverride` the focused workspace's, which starts from the opposite of the +window manager's value the first time. + `ToggleLock` locks the focused container, or unlocks it ([`WindowManager::toggle_lock`]), and `LockMonitorWorkspaceContainer` and `UnlockMonitorWorkspaceContainer` do the same for a container given by its indices. A lock changes nothing until containers are inserted, removed or @@ -522,10 +597,10 @@ module's unit. operations on it (tile the workspace in front, tile every monitor, take a minimized window out, move the focus, move the focused container, change the layout, resize or promote the focused container, float a window, lock a container, set the padding of a - workspace, manage and unmanage a window, show a workspace, move a container to another - workspace, follow a workspace switch made by something else, name a workspace and find - it by its name, set the layout of a workspace that is not in front, focus another monitor, - and move containers and workspaces between monitors). + workspace, manage and unmanage a window, stack and unstack windows, show a workspace, move a + container to another workspace, follow a workspace switch made by something else, name a + workspace and find it by its name, set the layout of a workspace that is not in front, focus + another monitor, and move containers and workspaces between monitors). - [`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 @@ -537,7 +612,8 @@ module's unit. - [`kwin`](../../komorebi/src/kwin/mod.rs): the KWin backend, its D-Bus service and the held call; [`kwin::state`](../../komorebi/src/kwin/state.rs), the table; [`kwin::kwinrc`](../../komorebi/src/kwin/kwinrc.rs), the editor of the user's `kwinrc`, which - keeps the values it changes and puts them back; and + keeps the values it changes and puts them back; the parking desktop is made, swept and removed + in `kwin` and tracked in `kwin::state`; and [`bridge.js`](../../komorebi/src/kwin/bridge.js), the bridge. - [`process_command`](../../komorebi/src/process_command.rs): the thread that listens on the socket, and what the window manager does with each message. @@ -583,6 +659,10 @@ the fake. The window manager is its only caller, through [`backend`]. The contra backend asked for a workspace that has no desktop makes the desktops up to it (`ensure_workspaces`). The compositor shows and hides the windows of the desktops, and a backend reports a switch or a move it did not make as an event. +- A hidden window (`hide`) is out of sight, alive, not minimized, and on the workspace it was + on until `restore`: `window_workspace` still names that workspace, and + `set_window_workspace` changes where it comes back to, not where it is hidden. Only the back of + a stack is hidden: nothing is hidden to leave a workspace. - `shutdown`, `is_window`, `window_rect` and `monitor_from_point` have defaults, written in terms of the queries. An implementor should not need to override them, except `shutdown` when it created something in the compositor that has to be given up. @@ -595,7 +675,7 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | A backend can be shared between threads | type system: `Backend: Send + Sync + 'static` | `compile_fail,E0277` on [`Backend`] | none | | The window manager asks for a backend that was never installed | runtime error: [`backend`] panics, naming [`set_backend`] and [`with_backend`] | `# Panics` on [`backend`] | none | | A test's backend does not outlive the test | framework path: [`with_backend`] restores the previous one on drop | `with_backend_puts_the_previous_backend_back_when_the_closure_panics` | none | -| A command about a missing window is an error, not a panic | test | `a_command_about_a_window_nobody_opened_is_an_error` and `a_command_about_a_closed_window_is_an_error` against the fake, `a_command_about_a_window_the_table_does_not_know_is_an_error_and_sends_nothing` against the KWin backend | none | +| A command about a missing window is an error, not a panic | test | `a_command_about_a_window_nobody_opened_is_an_error`, `a_command_about_a_closed_window_is_an_error` and `hiding_or_restoring_a_window_nobody_opened_is_an_error` against the fake, `a_command_about_a_window_the_table_does_not_know_is_an_error_and_sends_nothing` against the KWin backend | none | | A locked container keeps its index | framework path: a [`Workspace`] inserts, removes and swaps its containers through `LockableSequence` | `test_locked_containers_with_new_window`, `test_locked_containers_remove_window`, `a_window_that_opens_does_not_push_a_locked_container_from_its_index` | `containers_mut()` is the `VecDeque` itself | | A window komorebi does not manage is never moved | framework path: `handle_event` asks [`Window::should_manage`] before it acts on any event but a destroy | `dialogs_and_other_non_normal_windows_are_left_alone`, `an_ignore_rule_keeps_a_window_unmanaged` | `ManageRule` | | A window the user unmanaged stays out of the layout until it is managed again | framework path: `handle_event` checks the list of unmanaged windows before it acts on any event but a removal | `unmanage_takes_the_focused_window_out_until_manage_takes_it_back` | none | @@ -630,6 +710,15 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | A workspace is closed only when it has no window and no name and is not the monitor's last | framework path: the `CloseWorkspace` arm of [`WindowManager::process_command`] checks all three | `new_workspace_adds_one_and_close_workspace_removes_it_when_it_is_empty_and_unnamed` | none | | The focus and a move at the edge of a monitor go to the monitor on that side, and fail only when there is none | framework path: the `None` arms of [`WindowManager::focus_container_in_direction`] and [`WindowManager::move_container_in_direction`] | `the_focus_crosses_to_the_monitor_on_that_side`, `a_move_at_the_edge_of_a_monitor_swaps_with_the_window_on_the_next_one_by_default`, `focus_towards_a_side_with_no_window_is_an_error_and_changes_nothing` | `NoOp` keeps a move on its monitor | | A window shown or focused on another monitor focuses that monitor, and nothing else does | framework path: `handle_event` focuses the monitor of a `Show` or `FocusChange` before it acts on it | `a_window_that_opens_on_the_monitor_that_is_not_focused_focuses_it`, `a_scrolling_strip_on_the_next_monitor_over_does_not_take_focus_with_it` | none | +| Only the window in front of a stack is in sight | framework path: [`Container::add_window`] and [`Container::load_focused_window`] hide every window of the container but the focused one, and every operation that changes the front ends with the call | `a_window_added_to_a_container_is_in_front_and_the_others_are_hidden`, `loading_the_focused_window_shows_it_and_hides_the_rest`, `stacking_puts_two_windows_in_one_container_and_hides_the_one_behind`, `vm-stacks` ("exactly one window is on it") | none | +| A hidden window is still on its workspace, and is not reported as moved | framework path: the table keeps the desktop a window came from, and a move to the parking desktop is its own | `parking_a_window_hides_it_without_unmanaging_it`, `komorebis_own_move_and_its_park_and_restore_raise_nothing_new`, `a_window_is_put_on_a_workspace_by_putting_it_on_its_desktop` | none | +| What the compositor reports about a window komorebi hid is not taken for the user's doing | framework path: [`Window::hide`] records the window in [`HIDDEN_WINDOWS`] before it asks the backend, and the `Minimize` arm of `handle_event` checks it | `a_minimize_komorebi_asked_for_is_not_mistaken_for_the_user`, `a_minimize_the_user_asked_for_takes_the_window_out_of_the_layout` | none | +| Closing the window in front of a stack brings the next one forward | framework path: [`Workspace::remove_window`] shows the container's new front window and focuses it | `closing_the_window_in_front_of_a_stack_brings_the_next_one_forward`, `vm-stacks` ("closing the window in view") | none | +| The back of a stack stays hidden when its workspace is shown again | framework path: [`Workspace::restore`] restores the front window of each container and no other | `the_back_of_a_stack_stays_hidden_across_a_workspace_round_trip`, `vm-stacks` ("a workspace round trip") | none | +| A window is never sent to a parking desktop that is not there | framework path: [`Table::hide_commands`] asks for a new one unless KWin lists the one it made | `a_parking_desktop_that_has_gone_is_made_again_not_sent_windows` | none | +| A monitor raised onto the parking desktop is sent back | framework path: the table sends the monitor back to the desktop it showed before | `following_a_hidden_window_to_the_parking_desktop_sends_that_output_back` | none | +| A clean stop leaves no window hidden and no parking desktop | framework path: [`WindowManager::stop`] and the signal handler restore every hidden window, and the backend's `shutdown` removes the desktop after the restoring commands have gone out | `restoring_every_window_brings_back_the_hidden_ones_unless_it_is_told_to_ignore_that`, `vm-stacks` ("a clean stop puts every window back") | `SIGKILL`, and `StopIgnoreRestore`, which restores nothing | +| A parking desktop a dead daemon left is swept by the next one, and its windows go home | startup check: the first snapshot marks it stale, sends its windows to the first workspace's desktop, and the backend removes it | `a_parking_desktop_left_by_a_crashed_run_is_swept`, `a_stale_parking_desktop_is_not_the_one_windows_are_hidden_on`, `vm-stacks` ("a crash leaves a parking desktop") | 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 | @@ -767,6 +856,10 @@ A window is shown, and ends up on a tile: [`komorebi/tests/monitors.rs`](../../komorebi/tests/monitors.rs), two monitors: crossing from one to the other, the monitor commands and `EagerFocus`; [`komorebi/src/kwin/kwinrc.rs`](../../komorebi/src/kwin/kwinrc.rs), the `kwinrc` editor and its tests. +- [`komorebi/tests/stacks.rs`](../../komorebi/tests/stacks.rs): stacking, unstacking and the windows + a stack hides, the behavior of new windows and the float override, against the fake; + [`nix/tests/vm/suites/stacks`](../../nix/tests/vm/suites/stacks/test.py), the same in a real + KWin, with the parking desktop, a crash and a stop. - [`nix/tests/vm/suites/workspace-switch`](../../nix/tests/vm/suites/workspace-switch/test.py): workspaces as a real KWin's desktops, switched by `komorebic` and by KWin; [`nix/tests/vm/suites/kwin-contract`](../../nix/tests/vm/suites/kwin-contract/test.py), the @@ -798,6 +891,30 @@ A window is shown, and ends up on a tile: [`MatchingRule`]: ../../komorebi/src/core/config_generation.rs [`ApplicationIdentifier`]: ../../komorebi/src/core/mod.rs [`Window::set_position`]: ../../komorebi/src/window.rs +[`Window::hide`]: ../../komorebi/src/window.rs +[`Window::restore`]: ../../komorebi/src/window.rs +[`HIDDEN_WINDOWS`]: ../../komorebi/src/lib.rs +[`Container::add_window`]: ../../komorebi/src/container.rs +[`Container::load_focused_window`]: ../../komorebi/src/container.rs +[`Workspace::move_window_to_container`]: ../../komorebi/src/workspace.rs +[`Workspace::new_container_for_focused_window`]: ../../komorebi/src/workspace.rs +[`Workspace::remove_window`]: ../../komorebi/src/workspace.rs +[`Workspace::focus_container_by_window`]: ../../komorebi/src/workspace.rs +[`Workspace::restore`]: ../../komorebi/src/workspace.rs +[`Workspace::window_container_behaviour`]: ../../komorebi/src/workspace.rs +[`Workspace::float_override`]: ../../komorebi/src/workspace.rs +[`WindowManager::add_window_to_container`]: ../../komorebi/src/window_manager.rs +[`WindowManager::remove_window_from_container`]: ../../komorebi/src/window_manager.rs +[`WindowManager::stack_all`]: ../../komorebi/src/window_manager.rs +[`WindowManager::unstack_all`]: ../../komorebi/src/window_manager.rs +[`WindowManager::restore_all_windows`]: ../../komorebi/src/window_manager.rs +[`WindowManager::window_management_behaviour`]: ../../komorebi/src/window_manager.rs +[`Backend::hide`]: ../../komorebi/src/backend/mod.rs +[`Table::hide_commands`]: ../../komorebi/src/kwin/state.rs +[`Table::restore_commands`]: ../../komorebi/src/kwin/state.rs +[`Table::window_workspace`]: ../../komorebi/src/kwin/state.rs +[`Table::stale_park`]: ../../komorebi/src/kwin/state.rs +[`NeedsPark`]: ../../komorebi/src/kwin/state.rs [`Workspace::new_container_for_window`]: ../../komorebi/src/workspace.rs [`WindowManager`]: ../../komorebi/src/window_manager.rs [`WindowManager::process_event`]: ../../komorebi/src/process_event.rs diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index 34f6536d..5ae2b583 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -259,3 +259,40 @@ plain ini, which komorebi changes line by line, and asks KWin to reload its conf It is the user's setting, so the value komorebi found is kept in a backup file and put back when komorebi stops; a backup a crash left behind keeps the value from before the first run, and the next run puts it back. + +## Why are the windows behind the front one of a stack moved to a parking desktop? + +Because a container has one tile, so the windows of a stack that are not in front have to be out +of sight, and a virtual desktop nobody looks at was the one way to do that which the spikes found +to cost no focus change. The first spike hid six tiled windows and showed them again three ways. +Minimizing +them gave the geometry back exactly, but KWin picked a new active window as each one minimized, +two focus events nobody asked for. Moving them to a virtual desktop of their own gave no focus +event and the geometry back exactly. Moving them off the screen worked too, since KWin does not +clamp a scripted move, but the windows stay mapped on the current desktop, in the window switcher, +the overview and the task manager, so it was not offered. KWin has nothing else: `Window.hidden` +cannot be set by a script. The spike's source reading, KWin 6.8.1, is in +[PLATFORM.md](PLATFORM.md#the-parking-desktop). The other platforms have a setting that chooses +how windows are hidden; on KDE there is one way, and so no such setting. + +The cost is that KWin keeps a virtual desktop in `kwinrc` as soon as it is made. A run that failed +after making the parking desktop left it there for every later session. So komorebi removes the +desktop when it stops, after the commands that bring the windows back have reached the bridge, and +the first snapshot of the next run finds a leftover, sends the windows on it to the first +workspace's desktop and removes it. A leftover is never used as the parking desktop of the new +run, since it is about to be removed and anything parked on it would go with it; the new run makes +its own the first time it needs one. Before each hide the table also checks that the desktop it +made is still listed, because somebody can remove it in System Settings, and a window sent to a +desktop that is not there has nowhere to go and nothing says so. + +Activating a window that is on another desktop makes KWin switch that output to the window's +desktop (see [PLATFORM.md](PLATFORM.md#per-output-virtual-desktops)), so a click on a hidden +window in the task manager would leave the monitor showing the parking desktop, which has nothing +on it but hidden windows. The table sends the monitor straight back to the desktop it showed +before, and the window's own focus change then brings it to the front of its stack. + +komorebi keeps a table of the windows it hid, [`HIDDEN_WINDOWS`](../../komorebi/src/lib.rs), and +records a window in it before it asks the backend to hide it. What the compositor reports about a +hidden window (a minimize, a move to another desktop) is the echo of a command komorebi sent, and +the window manager has to be able to tell that from the user's own: a window that the user +minimizes leaves the layout, and one komorebi hid does not. diff --git a/docs/dev/PLATFORM.md b/docs/dev/PLATFORM.md index efa56ec5..50e5ba32 100644 --- a/docs/dev/PLATFORM.md +++ b/docs/dev/PLATFORM.md @@ -21,6 +21,8 @@ was measured with KWin 6.8.1, Plasma 6.8.1 and Qt 6.12.0 from nixpkgs `d3b13ad` - **Output**: KWin's name for a monitor. - **Desktop**: one of KWin's virtual desktops. Each output shows one; komorebi's workspace `i` is desktop `i`. +- **Parking desktop**: the desktop named `komorebi-park`, which komorebi makes to hide the back of + a stack on. It is not a workspace. - **Headless KWin**: `kwin_wayland --virtual`, a KWin with virtual outputs and no display, which the VM tests run ([vm-tests.md](vm-tests.md)). @@ -58,6 +60,29 @@ outputs: - Desktops are global: every output has the same desktops, with the same names, and each output shows one of them. The ids of the desktops are UUIDs; the bridge names them by id. +### The parking desktop + +Why komorebi hides windows on a desktop of its own is in +[DESIGN.md](DESIGN.md#why-are-the-windows-behind-the-front-one-of-a-stack-moved-to-a-parking-desktop). +What it relies on KWin for, at time of writing (2026-10-27), measured by the spikes on KWin +6.8.1 (nixpkgs `d3b13ad`, 2026-10-24) unless it says otherwise. The VM test `vm-stacks` runs +komorebi's use of them in a headless KWin: + +- A window on another desktop of its output is out of sight, and moving it there and back gives + its frame back exactly. Hiding six windows this way raised no `activated` event; minimizing + them raised two. +- KWin saves a virtual desktop in `kwinrc` as soon as it is made. A desktop that komorebi made + and did not remove, because it was killed, is in every later session. +- Activating a window that is on another desktop of its output switches that output to the + window's desktop (see [Per-output virtual desktops](#per-output-virtual-desktops)), which for a + hidden window is the parking desktop. +- `Window.hidden` is read-only to a script, and the only thing in KWin's 6.8.1 sources that sets + it is a layer-shell window (an auto-hide panel). This is a reading of the sources, not a + measurement. +- A window on the parking desktop is still a window of the session. Whether Plasma's task manager + lists it depends on the task manager's setting for showing the windows of the current desktop + only; the VM tests have no panel, so this is not measured. + ### Wayland komorebi runs in a Plasma Wayland session. There is no X11 session of Plasma 6.8 to run it in. @@ -107,8 +132,15 @@ reload its configuration. If a workspace has no desktop, it creates the missing `org.kde.KWin.VirtualDesktopManager`, named "Workspace 1", "Workspace 2" and so on. A desktop that is already there keeps its name. -Every command of the window manager (move and resize, focus, raise, minimize, bring back or -close a window, show a workspace on an output, put a window on a workspace) is queued for the +When a stack hides a window for the first time, it makes one more desktop, named `komorebi-park`, +after the desktops of the workspaces (the missing workspace desktops are made by position, so that +it stays last), and moves the window there. The desktop is not a workspace: komorebi counts the +workspaces without it, and a window on it is on the workspace it will come back to. It checks that +KWin still lists the desktop before it sends another window there. + +Every command of the window manager (move and resize, focus, raise, hide or restore, minimize, +bring back or close a window, show a workspace on an output, put a window on a workspace) is +queued for the bridge, which gets the queue as the answer to its held call and carries the commands out in KWin, in order. The script API cannot move the pointer, so when the pointer follows the focus komorebi invokes KWin's own "Move Mouse to Focus" shortcut, @@ -128,15 +160,20 @@ an owner again; the VM test `vm-kwin-restart` restarts KWin twice under a runnin #### What komorebi leaves behind -When it is stopped with `SIGINT`, `SIGTERM` or a `Stop` message on its socket, it puts back -the value `PerOutputVirtualDesktops` had in `kwinrc` (or removes the key, when the user had -none) and asks KWin to reload its configuration, asks KWin to unload the script, closes an -empty connection to each subscriber, and removes the socket. The desktops it created stay: -they are the user's desktops from then on. The file `bridge.js` stays in the runtime +When it is stopped with `SIGINT`, `SIGTERM` or a `Stop` message on its socket, it brings back +the windows it hid, waits until the commands that do so have reached the bridge, removes the +parking desktop, puts back the value `PerOutputVirtualDesktops` had in `kwinrc` (or removes the +key, when the user had none) and asks KWin to reload its configuration, asks KWin to unload the +script, closes an empty connection to each subscriber, and removes the socket. The desktops it +created for workspaces stay: they are the user's desktops from then on. A `StopIgnoreRestore` +message stops it the same way, except that it does not bring the windows back: the parking desktop +is removed all the same. The file `bridge.js` stays in the runtime directory, which is removed at logout. A daemon that is killed leaves the script running in -KWin, reporting to nobody, the socket file in the runtime directory, and per-output desktops -on, until the next start replaces the script and the socket, and its stop puts the setting -back (the backup in the runtime directory keeps the user's value), or the session ends. +KWin, reporting to nobody, the socket file in the runtime directory, per-output desktops +on, and the parking desktop with the hidden windows on it, until the next start replaces the +script and the socket, sends the windows on the parking desktop to the first workspace's desktop +and removes the desktop, and its stop puts the setting back (the backup in the runtime +directory keeps the user's value), or the session ends. ### A headless KWin @@ -182,7 +219,7 @@ What komorebi calls: | `org.freedesktop.DBus` | `/org/freedesktop/DBus` | `org.freedesktop.DBus` | `AddMatch(s)`, for the signal `NameOwnerChanged` | at start, to see KWin leave the bus and come back | | `org.kde.kglobalaccel` | `/component/kwin` | `org.kde.kglobalaccel.Component` | `invokeShortcut(s)`, with `MoveMouseToFocus` | when the pointer follows the focus | | `org.kde.KWin` | `/KWin` | `org.kde.KWin` | `reconfigure()` | after komorebi changed `kwinrc`, at start and at exit | -| `org.kde.KWin` | `/VirtualDesktopManager` | `org.kde.KWin.VirtualDesktopManager` | the property `desktops`, `createDesktop(us)`, `setDesktopName(ss)` | when a workspace has no desktop, and at start | +| `org.kde.KWin` | `/VirtualDesktopManager` | `org.kde.KWin.VirtualDesktopManager` | the properties `desktops` and `count`, `createDesktop(us)`, `setDesktopName(ss)`, `removeDesktop(s)` | when a workspace has no desktop, and at start; `createDesktop` also for the parking desktop, the first time a window is hidden, and `removeDesktop` for it when komorebi stops and for one a killed run left | The property `desktops` is introspected as `a(iss)`, a position, an id and a name for each desktop, and the value on the wire is `a(uss)`: komorebi reads it as that. It is read uncached, diff --git a/docs/dev/vm-tests.md b/docs/dev/vm-tests.md index 28e1a8a3..425e5403 100644 --- a/docs/dev/vm-tests.md +++ b/docs/dev/vm-tests.md @@ -118,17 +118,18 @@ the run. clients. - `start_komorebi(*args)` starts the daemon in the session, with the module's `execStart` and `args` after it (not `--log-level`, which `execStart` has), - `stop_komorebi()` stops it with `SIGTERM`, `komorebi_log()` returns its log - since it was last started, and `script_loaded()` asks KWin whether it runs - the bridge. + `stop_komorebi()` stops it with `SIGTERM`, `kill_komorebi()` kills it with + `SIGKILL`, as a crash does, `komorebi_log()` returns its log since it was + last started, and `script_loaded()` asks KWin whether it runs the bridge. `restart_kwin()` stops the session's KWin and starts a new one on the same bus, as when the compositor of a running session crashes, and waits for it. - `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, `managed_all()` the number it - has on every workspace of every monitor (floating windows too), and - `focused_title(workspace)` the title of the window it has focused there. + has on every workspace of every monitor (floating windows too), + `containers(workspace)` the titles of the windows of each of its containers, + 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 @@ -136,7 +137,9 @@ the run. for, for each window. - `desk(wid)` is the number of the desktop a window is on and `shown()` the one KWin shows, both counted from 1 as `kdotool` counts, `desktops()` how many - desktops KWin has, and `kwinrc(group, key)` a value of the user's `kwinrc`. + desktops KWin has, `desktop_names()` their names in order (the parking desktop + of a stack is the one named `komorebi-park`), and `kwinrc(group, key)` a value + of the user's `kwinrc`. - `kwin_script(name, source, keep=)` runs a KWin script and returns the lines it reported: the script calls `report(...)`, which `print`s a line marked with `name` to KWin's log, and ends with `report("done")`. A script that is kept diff --git a/docs/index.md b/docs/index.md index 8ee8224b..e1449e3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,8 @@ anywhere else. [Running komorebi](usage/running-komorebi.md) explains how to sta 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, [Moving windows](usage/moving-windows.md) how to move the focused window to another tile, +[Stacking windows](usage/stacking-windows.md) how to put windows on one tile and have the others +wait out of sight, [Layouts](usage/layouts.md) how to change the arrangement of the tiles, [Resizing windows](usage/resizing-windows.md) how to change the size of one, [Floating windows](usage/floating-windows.md) how to take a window out of the layout, diff --git a/docs/usage/floating-windows.md b/docs/usage/floating-windows.md index 83b44797..fecad0d6 100644 --- a/docs/usage/floating-windows.md +++ b/docs/usage/floating-windows.md @@ -13,3 +13,20 @@ komorebic toggle-float A floated window keeps its size and position by default. The floating windows of a workspace are in the daemon's state as `floating_windows`, next to its containers. + +## Windows that open floating + +With the float override on, every window that opens floats instead of getting a tile. +[`komorebic toggle-float-override`](../cli/toggle-float-override.md) turns it on and off for every +workspace, and +[`komorebic toggle-workspace-float-override`](../cli/toggle-workspace-float-override.md) for the +focused workspace only: the first time it takes the opposite of what every workspace has, and from +then on it follows its own setting. + +```sh +komorebic toggle-float-override +komorebic toggle-workspace-float-override +``` + +The override is in the daemon's state, as `float_override` for every workspace and in each +workspace that has one of its own. diff --git a/docs/usage/running-komorebi.md b/docs/usage/running-komorebi.md index cf0e8020..c9b74700 100644 --- a/docs/usage/running-komorebi.md +++ b/docs/usage/running-komorebi.md @@ -45,9 +45,10 @@ komorebic start -- --log-level debug komorebic start -- --fake-backend ``` -[`komorebic stop`](../cli/stop.md) tells the daemon to stop. It asks KWin to unload its -script, removes its socket and exits. Stopping the process with `SIGINT` or `SIGTERM` does -the same. +[`komorebic stop`](../cli/stop.md) tells the daemon to stop. It brings back the windows it hid +(the back of a stack, [stacking windows](stacking-windows.md)), removes the desktop they were +hidden on, asks KWin to unload its script, removes its socket and exits. Stopping the process with +`SIGINT` or `SIGTERM` does the same. ## Asking komorebi what it is doing diff --git a/docs/usage/stacking-windows.md b/docs/usage/stacking-windows.md new file mode 100644 index 00000000..630aefc6 --- /dev/null +++ b/docs/usage/stacking-windows.md @@ -0,0 +1,81 @@ +# Stacking windows + +A stack is a container with more than one window. The windows share one tile of the layout, and +one of them, the window in front, is in sight: the others are out of the way. Stacking puts two +windows on one tile, so the layout has one tile fewer to give out. + +## Putting windows in a stack + +[`komorebic stack`](../cli/stack.md) puts the focused window and the window on one side of it in +one stack: `left`, `right`, `up` or `down`. As with +[`komorebic focus`](focusing-windows.md#in-a-direction), the layout decides which tile is on which +side. The focused window ends up in front, and it keeps the focus. Where the layout has no tile on +that side, nothing happens. + +```sh +komorebic stack left +komorebic stack down +``` + +[`komorebic unstack`](../cli/unstack.md) takes the window in front out of its stack and puts it +in a tile of its own after the stack, and the next window of the stack is in front again. A +window that is alone on its tile cannot be unstacked, and the daemon logs "a container must have +at least one window". + +```sh +komorebic unstack +``` + +[`komorebic stack-all`](../cli/stack-all.md) puts every window of the focused workspace in one +stack, in the order they had, with the focused window in front. +[`komorebic unstack-all`](../cli/unstack-all.md) takes every window out of the focused stack, +each onto a tile of its own, and the window that had the focus keeps it. + +```sh +komorebic stack-all +komorebic unstack-all +``` + +A stack moves as one: moving it with [`komorebic move`](moving-windows.md#in-a-direction) or to +another workspace takes every window in it. When the window in front closes, the next window of +the stack comes to the front and has the focus. + +## Windows that open into a stack + +By default a window that opens gets a tile of its own. With the window container behavior set to +`append`, a window that opens joins the focused stack instead, in front, and a workspace with no +window still gives it a tile of its own, since there is nothing to join. +[`komorebic toggle-window-container-behaviour`](../cli/toggle-window-container-behaviour.md) +switches every workspace between the two. +[`komorebic toggle-workspace-window-container-behaviour`](../cli/toggle-workspace-window-container-behaviour.md) +does it for the focused workspace only: the first time, it takes the opposite of what every +workspace has, and from then on it follows its own setting. + +```sh +komorebic toggle-window-container-behaviour +komorebic toggle-workspace-window-container-behaviour +``` + +The setting is in the daemon's state, as `new_window_behaviour` for every workspace and as +`window_container_behaviour` in each workspace that has one of its own. + +## Where the windows behind go + +komorebi does not minimize the windows behind the front one: it moves them to a virtual desktop +that nobody looks at, which it makes when it first needs it and calls `komorebi-park`. A window +there is still open, still on its workspace as far as komorebi is concerned, and still in the +stack, and it comes back to the desktop of its workspace when it is brought to the front, when it +is unstacked, and when komorebi stops. The parking desktop is the last desktop, after the ones +that are workspaces, and is not one of them. + +KWin keeps its virtual desktops in its configuration as soon as one is made, so the parking +desktop would be there in the next session if komorebi did not remove it. It does when it stops. +If komorebi did not get to stop (it was killed, or the session ended under it), the next komorebi +finds the parking desktop in KWin's list, sends the windows on it back to the first workspace's +desktop, and removes it. If the parking desktop is removed while komorebi runs, the next hide +makes a new one. + +A window on the parking desktop can still be reached by something else. Raising one, with a +click in the task manager, makes KWin show the parking desktop on that monitor, and komorebi +sends the monitor straight back to the workspace it was showing. The window that was raised +becomes the front of its stack when komorebi handles its focus change. diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index a0c037b7..b155a4ba 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -198,7 +198,7 @@ where /// # let daemon = std::thread::spawn(move || -> std::io::Result<()> { /// # let (mut stream, _) = listener.accept()?; /// # BufReader::new(stream.try_clone()?).read_line(&mut String::new())?; -/// # let state = r#"{"monitors":{"elements":[],"focused":0},"monitor_usr_idx_map":{},"is_paused":false,"resize_delta":50,"cross_monitor_move_behaviour":"Swap","unmanaged_window_operation_behaviour":"Op","work_area_offset":null,"mouse_follows_focus":true}"#; +/// # let state = r#"{"monitors":{"elements":[],"focused":0},"monitor_usr_idx_map":{},"is_paused":false,"resize_delta":50,"new_window_behaviour":"Create","float_override":false,"cross_monitor_move_behaviour":"Swap","unmanaged_window_operation_behaviour":"Op","work_area_offset":null,"mouse_follows_focus":true}"#; /// # stream.write_all(state.as_bytes()) /// # }); /// use komorebi_client::SocketMessage; @@ -260,7 +260,7 @@ pub fn send_query(message: &SocketMessage) -> std::io::Result { /// # let mut message = String::new(); /// # BufReader::new(stream).read_line(&mut message)?; /// # let subscriber = komorebi_client::DATA_DIR.join("bar.sock"); -/// # let state = r#"{"monitors":{"elements":[],"focused":0},"monitor_usr_idx_map":{},"is_paused":false,"resize_delta":50,"cross_monitor_move_behaviour":"Swap","unmanaged_window_operation_behaviour":"Op","work_area_offset":null,"mouse_follows_focus":true}"#; +/// # let state = r#"{"monitors":{"elements":[],"focused":0},"monitor_usr_idx_map":{},"is_paused":false,"resize_delta":50,"new_window_behaviour":"Create","float_override":false,"cross_monitor_move_behaviour":"Swap","unmanaged_window_operation_behaviour":"Op","work_area_offset":null,"mouse_follows_focus":true}"#; /// # let notification = format!(r#"{{"event":{message},"state":{state}}}"#); /// # std::os::unix::net::UnixStream::connect(subscriber)?.write_all(notification.as_bytes())?; /// # Ok(message) diff --git a/komorebi/src/backend/fake.rs b/komorebi/src/backend/fake.rs index 2c2c52c6..04b9f248 100644 --- a/komorebi/src/backend/fake.rs +++ b/komorebi/src/backend/fake.rs @@ -44,6 +44,10 @@ pub enum Command { Focus(u32, bool), /// [`Backend::raise`]. Raise(u32), + /// [`Backend::hide`]. + Hide(u32), + /// [`Backend::restore`]. + Restore(u32), /// [`Backend::minimize`]. Minimize(u32), /// [`Backend::unminimize`]. @@ -66,6 +70,8 @@ struct State { /// [`FakeBackend::permissive`]). permissive: bool, windows: HashMap, + /// the windows that are hidden, in the order they were + hidden: Vec, min_sizes: HashMap, monitors: Vec, foreground: Option, @@ -252,8 +258,14 @@ impl FakeBackend { self.state.lock().windows.get(&id).map(|w| w.rect) } - /// Returns true if and only if the window can be seen: it is alive, not minimized, and on the - /// workspace its monitor shows. This is what the compositor does with desktops. + /// Returns true if and only if the window is hidden ([`Backend::hide`]), as opposed to being + /// on a workspace that is not showing. + pub fn is_hidden(&self, id: u32) -> bool { + self.state.lock().hidden.contains(&id) + } + + /// Returns true if and only if the window can be seen: it is alive, not minimized, not hidden + /// and on the workspace its monitor shows. This is what the compositor does with desktops. pub fn on_screen(&self, id: u32) -> bool { let state = self.state.lock(); let Some(window) = state.windows.get(&id) else { @@ -264,7 +276,10 @@ impl FakeBackend { .and_then(|m| state.shown.get(&m)) .copied() .unwrap_or(0); - window.alive && !window.minimized && state.window_ws.get(&id).copied().unwrap_or(0) == shown + window.alive + && !window.minimized + && !state.hidden.contains(&id) + && state.window_ws.get(&id).copied().unwrap_or(0) == shown } /// Returns the workspace a window is on, if it was ever opened. @@ -398,6 +413,20 @@ impl Backend for FakeBackend { self.record(id, Command::Raise(id), |_| ()) } + fn hide(&self, id: u32) -> eyre::Result<()> { + self.record(id, Command::Hide(id), |state| { + if !state.hidden.contains(&id) { + state.hidden.push(id); + } + }) + } + + fn restore(&self, id: u32) -> eyre::Result<()> { + self.record(id, Command::Restore(id), |state| { + state.hidden.retain(|h| *h != id); + }) + } + fn minimize(&self, id: u32) -> eyre::Result<()> { self.record(id, Command::Minimize(id), |state| { if let Some(w) = state.windows.get_mut(&id) { @@ -680,6 +709,35 @@ mod tests { Ok(()) } + #[test] + fn a_hidden_window_is_out_of_sight_on_its_workspace_until_it_is_restored() -> eyre::Result<()> { + let fake = fake_with(&[1]); + + fake.hide(1)?; + fake.hide(1)?; + + assert!(fake.is_hidden(1) && !fake.on_screen(1)); + assert_eq!(fake.window_workspace(1), Some(0)); + assert!(fake.window(1).is_some_and(|w| w.alive && !w.minimized)); + + fake.restore(1)?; + + assert!(!fake.is_hidden(1) && fake.on_screen(1)); + assert_eq!( + fake.commands(), + [Command::Hide(1), Command::Hide(1), Command::Restore(1)] + ); + Ok(()) + } + + #[test] + fn hiding_or_restoring_a_window_nobody_opened_is_an_error() { + let fake = fake_with(&[1]); + + assert!(fake.hide(9).is_err()); + assert!(fake.restore(9).is_err()); + } + #[test] fn putting_a_window_nobody_opened_on_a_workspace_is_an_error() { let fake = fake_with(&[1]); diff --git a/komorebi/src/backend/mod.rs b/komorebi/src/backend/mod.rs index 7050c43b..e74612a4 100644 --- a/komorebi/src/backend/mod.rs +++ b/komorebi/src/backend/mod.rs @@ -28,6 +28,14 @@ //! to be put on workspace `i` ([`Backend::set_window_workspace`]), and the compositor does the //! showing, the hiding and the focusing. When something else changes what a monitor shows, the //! backend raises an event and the window manager follows it. +//! +//! # Hiding +//! +//! The one thing the window manager hides itself is the back of a stack: a container tiles one +//! window, so the others in it are taken out of sight ([`Backend::hide`]) and brought back +//! ([`Backend::restore`]) when they are given the tile. A hidden window stays alive, stays on its +//! workspace and is not minimized, so what the compositor reports about it is not taken for the +//! user's doing (the window manager keeps a table of the windows it has hidden). #![deny(missing_docs)] @@ -101,6 +109,9 @@ pub struct MonitorInfo { /// one of them at a time ([`Backend::switch_workspace`]). The compositor shows the windows of the /// workspace in front and hides the others; the window manager never hides a window to leave a /// workspace. +/// - A hidden window ([`Backend::hide`]) is still alive and still on its workspace: it is out of +/// sight until [`Backend::restore`], and [`Backend::window_workspace`] names the workspace it +/// comes back to. /// - A backend asked to show a workspace, or to put a window on one, that has no desktop yet makes /// the desktops up to it first. /// @@ -133,6 +144,8 @@ pub struct MonitorInfo { /// # fn set_rect(&self, _: u32, _: &Rect) -> eyre::Result<()> { Ok(()) } /// # fn focus(&self, _: u32, _: bool) -> eyre::Result<()> { Ok(()) } /// # fn raise(&self, _: u32) -> eyre::Result<()> { Ok(()) } +/// # fn hide(&self, _: u32) -> eyre::Result<()> { Ok(()) } +/// # fn restore(&self, _: u32) -> eyre::Result<()> { Ok(()) } /// # fn minimize(&self, _: u32) -> eyre::Result<()> { Ok(()) } /// # fn unminimize(&self, _: u32) -> eyre::Result<()> { Ok(()) } /// # fn close(&self, _: u32) -> eyre::Result<()> { Ok(()) } @@ -192,6 +205,28 @@ pub trait Backend: Send + Sync + 'static { /// When the window does not exist or is no longer alive. fn raise(&self, id: u32) -> eyre::Result<()>; + /// Takes the window out of sight without closing or minimizing it, which is how the window + /// manager keeps the back of a stack out of the way. On KWin it goes to a virtual desktop that + /// nobody looks at. + /// + /// The window stays on its workspace ([`Backend::window_workspace`]), and + /// [`Backend::set_window_workspace`] changes the workspace it comes back to, not where it is + /// hidden. A window that is hidden already is left as it is. + /// + /// # Errors + /// + /// When the window does not exist or is no longer alive, or the compositor cannot make the + /// place the window is hidden in. + fn hide(&self, id: u32) -> eyre::Result<()>; + + /// Brings a hidden window back, to the workspace it is on. A window that is not hidden is left + /// as it is. + /// + /// # Errors + /// + /// When the window does not exist or is no longer alive. + fn restore(&self, id: u32) -> eyre::Result<()>; + /// Minimizes the window. /// /// # Errors diff --git a/komorebi/src/container.rs b/komorebi/src/container.rs index e35450ee..9104284a 100644 --- a/komorebi/src/container.rs +++ b/komorebi/src/container.rs @@ -4,6 +4,10 @@ //! the container is put on that tile. A new window gets a container of its own //! ([`Workspace::new_container_for_window`](crate::workspace::Workspace::new_container_for_window)). //! +//! A container with more than one window is a stack: the focused window is the one in front, and +//! the others are hidden ([`Window::hide`]) until they are given the tile +//! ([`Container::load_focused_window`]). +//! //! A container can be locked ([`Lockable`]): a locked container keeps its index in the workspace //! while others are inserted, removed or swapped around it. @@ -100,10 +104,22 @@ impl Container { self.remove_window_by_idx(focused_idx) } + /// Puts a window at the back of the container and gives it the front, hiding every other + /// window of the container ([`Window::hide`]) + /// + /// It never fails; the result is there so that callers can use `?`. pub fn add_window(&mut self, window: &Window) -> eyre::Result<()> { self.windows_mut().push_back(*window); self.focus_window(self.windows().len().saturating_sub(1)); + let focused_window_idx = self.focused_window_idx(); + + for (i, window) in self.windows_mut().iter_mut().enumerate() { + if i != focused_window_idx { + window.hide(); + } + } + Ok(()) } @@ -113,17 +129,73 @@ impl Container { self.windows.focus(idx); } + /// Hides the unfocused windows of the container and restores the focused one. This function + /// is used to make sure we update the window that should be shown on a stack. If the container + /// isn't a stack this function won't change anything. + /// + /// It never fails; the result is there so that callers can use `?`. + pub fn load_focused_window(&mut self) -> eyre::Result<()> { + let focused_idx = self.focused_window_idx(); + + for (i, window) in self.windows_mut().iter_mut().enumerate() { + if i == focused_idx { + window.restore(); + } else { + window.hide(); + } + } + + Ok(()) + } + pub fn remove_window_by_idx(&mut self, idx: usize) -> Option { let window = self.windows_mut().remove(idx); self.focus_window(idx.saturating_sub(1)); window } + + /// Hides every window of the container but `omit`, or every window when it is `None` + /// + /// It never fails; the result is there so that callers can use `?`. + pub fn hide(&mut self, omit: Option) -> eyre::Result<()> { + for window in self.windows_mut().iter_mut().rev() { + let mut should_hide = omit.is_none(); + + if !should_hide + && let Some(omit) = omit + && omit != window.id + { + should_hide = true + } + + if should_hide { + window.hide(); + } + } + + Ok(()) + } + + /// Restores the focused window of the container, which is the one that is in front when the + /// container is a stack + /// + /// It never fails; the result is there so that callers can use `?`. + pub fn restore(&mut self) -> eyre::Result<()> { + if let Some(window) = self.focused_window_mut() { + window.restore(); + } + + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use crate::backend::fake::FakeBackend; + use crate::backend::with_backend; use serde_json; + use std::sync::Arc; #[test] fn test_contains_window() { @@ -243,4 +315,65 @@ mod tests { assert!(deserialized.locked); assert_eq!(deserialized.id, container.id); } + + /// A container of the windows 701.. on a fake desktop, with the window at `focus` in front + fn stack_of( + fake: &Arc, + ids: std::ops::RangeInclusive, + focus: usize, + ) -> Container { + let mut container = Container::default(); + for id in ids { + fake.open_window(id, "foot", "foot", &format!("window {id}")); + container.add_window(&Window::from(id)).unwrap(); + } + container.focus_window(focus); + container + } + + #[test] + fn a_window_added_to_a_container_is_in_front_and_the_others_are_hidden() { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + let container = stack_of(&fake, 701..=703, 2); + + assert_eq!(container.focused_window().map(|w| w.id), Some(703)); + assert!(fake.is_hidden(701) && fake.is_hidden(702)); + assert!(!fake.is_hidden(703)); + }); + } + + #[test] + fn loading_the_focused_window_shows_it_and_hides_the_rest() { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + let mut container = stack_of(&fake, 704..=706, 0); + + container.load_focused_window().unwrap(); + + assert!(!fake.is_hidden(704)); + assert!(fake.is_hidden(705) && fake.is_hidden(706)); + }); + } + + #[test] + fn hiding_a_container_leaves_out_the_window_it_is_told_to_and_restoring_shows_the_focused_one() + { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + let mut container = stack_of(&fake, 707..=709, 1); + + container.hide(Some(709)).unwrap(); + assert!(fake.is_hidden(707) && fake.is_hidden(708) && !fake.is_hidden(709)); + + container.hide(None).unwrap(); + assert!(fake.is_hidden(709)); + + container.restore().unwrap(); + assert!(fake.is_hidden(707) && !fake.is_hidden(708) && fake.is_hidden(709)); + }); + } } diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 2e6b1622..66b2731d 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -347,6 +347,10 @@ pub enum SocketMessage { CancelPreselect, CycleFocusWindow(CycleDirection), CycleMoveWindow(CycleDirection), + StackWindow(OperationDirection), + UnstackWindow, + StackAll, + UnstackAll, ResizeWindowEdge(OperationDirection, Sizing), ResizeWindowAxis(Axis, Sizing), MoveContainerToLastWorkspace, @@ -378,6 +382,8 @@ pub enum SocketMessage { UnlockMonitorWorkspaceContainer(usize, usize, usize), ToggleLock, ToggleFloat, + ToggleWindowContainerBehaviour, + ToggleFloatOverride, ToggleCrossMonitorMoveBehaviour, CrossMonitorMoveBehaviour(MoveBehaviour), UnmanagedWindowOperationBehaviour(OperationBehaviour), @@ -392,6 +398,8 @@ pub enum SocketMessage { LayoutRatios(Option>, Option>), // ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), FlipLayout(Axis), + ToggleWorkspaceWindowContainerBehaviour, + ToggleWorkspaceFloatOverride, // Monitor and Workspace Commands // MonitorIndexPreference(usize, i32, i32, i32, i32), DisplayIndexPreference(usize, String), @@ -400,6 +408,7 @@ pub enum SocketMessage { NewWorkspace, ToggleTiling, Stop, + StopIgnoreRestore, TogglePause, Retile, RetileWithResizeDimensions, diff --git a/komorebi/src/kwin/mod.rs b/komorebi/src/kwin/mod.rs index 3825d6d7..e8ed0811 100644 --- a/komorebi/src/kwin/mod.rs +++ b/komorebi/src/kwin/mod.rs @@ -42,6 +42,13 @@ //! When something other than komorebi does either, the script reports it, and the table turns it //! into a [`WorkspaceChange`] or a [`WorkspaceMove`] for the window manager to follow. //! +//! Hiding a window ([`Backend::hide`]) moves it to one more desktop, `komorebi-park`, that nobody +//! looks at and that is not a workspace; [`Backend::restore`] moves it back to the desktop of its +//! workspace. KWin saves its desktops in `kwinrc` as soon as one is made, so a desktop that +//! komorebi made and did not remove would be there for every later session: komorebi removes it +//! when it stops, and one that an earlier run left (it was killed, or the session was torn down +//! under it) is found in the first snapshot, its windows are sent home and it is removed. +//! //! [`WorkspaceChange`]: crate::window_manager_event::WindowManagerEvent::WorkspaceChange //! [`WorkspaceMove`]: crate::window_manager_event::WindowManagerEvent::WorkspaceMove //! @@ -67,7 +74,9 @@ use parking_lot::Mutex; use serde_json::Value; use serde_json::json; use state::NeedsDesktops; +use state::NeedsPark; use state::Output; +use state::PARK_DESKTOP_NAME; use state::Table; use std::collections::HashMap; use std::collections::VecDeque; @@ -142,6 +151,7 @@ impl Shared { for output in outputs { match output { Output::Event(event) => window_manager_event_listener::send_notification(event), + Output::Command(command) => self.push(command), Output::Cursor { token, x, y } => { if let Some(waiter) = self.cursor_waiters.lock().remove(&token) { let _ = waiter.send((x, y)); @@ -188,6 +198,9 @@ impl Events { if is_snapshot { self.shared.mark_ready(); + // a parking desktop in a snapshot is one nobody is using: from an earlier run, or one + // that KWin kept in its configuration when it restarted under this one + sweep_stale_park(&self.shared); } } } @@ -526,6 +539,23 @@ async fn supervise(shared: Arc) { } } +/// Removes the parking desktop an earlier run left behind, once its windows have moved home: the +/// snapshot that found it sent them, and they take a moment to get there +fn sweep_stale_park(shared: &Arc) { + let Some(stale) = shared.table.lock().stale_park().map(String::from) else { + return; + }; + + let shared = shared.clone(); + shared.handle.clone().spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + if let Err(error) = remove_desktop(&shared, &stale).await { + tracing::warn!("could not remove the stale parking desktop: {error}"); + } + shared.table.lock().clear_stale_park(); + }); +} + async fn desktop_manager(shared: &Shared) -> eyre::Result> { let connection = shared.connection().await?; // uncached: a property read right after createDesktop must not return the list from before it @@ -543,6 +573,33 @@ async fn desktops(manager: &Proxy<'static>) -> eyre::Result