mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-14 21:04:04 +01:00
komorebi's workspaces exist on Windows and macOS because neither has a virtual desktop API: switching hides every window of the workspace being left. KWin has virtual desktops, per output since 6.8, so here workspace i is KWin's desktop i on every monitor, and each monitor shows one. KWin shows, hides and focuses the windows of a desktop; komorebi asks for a switch, puts a window on a desktop, and follows what anything else does. The Backend gets the workspace methods (ensure_workspaces, switch_workspace, set_window_workspace, window_workspace, current_workspaces), and the fake its desktops. The bridge reports the desktops, the desktop each output shows and a window's desktops, and carries out output_desktop and desktop commands, typed and tested with the fixtures like the rest. The table turns a switch or a move komorebi did not make into a WorkspaceChange or a WorkspaceMove, and records its own when it sends them, so that KWin's report of those raises nothing. The window manager follows a switch with no command in reply (adopt_workspace_change), puts windows on their desktops before a desktop is shown (focusing a window first would take the monitor back), and loads the windows open at startup onto the workspace of their desktop. Per-output desktops are a kwinrc setting that is off by default, and kwriteconfig6 was not on the PATH of the user service in a live session, so komorebi edits kwinrc itself (kwin::kwinrc) and puts the user's value back when it stops, also after a crash. focus-workspace, move-to-workspace and send-to-workspace come with this commit, since the tests of switching and moving, and vm-workspace-switch, need them; the named and cycling workspace commands follow. vm-workspace-switch runs the daemon against a real KWin; vm-kwin-contract checks what komorebi relies on from KWin's per-output desktops, on a headless KWin with two outputs and no komorebi.
262 lines
16 KiB
Markdown
262 lines
16 KiB
Markdown
# Design
|
|
|
|
This document explains why komorebi for KDE is built the way it is, one decision at a time.
|
|
It is opinionated: these are the reasons behind the code, and reasonable people may disagree
|
|
with them. Where a reason was not written down, the entry says that it needs one.
|
|
|
|
It grows with the code: a decision is added in the commit that makes it.
|
|
|
|
## What is komorebi for KDE the port of?
|
|
|
|
komorebi has a Windows implementation, the original, and a macOS one. The Windows one is
|
|
the reference. I used it as the reference for what to adapt, and I prefer what KDE and
|
|
Wayland do natively over a workaround that the other two platforms need. The events the
|
|
window manager reacts to follow the Windows ones (a window is shown, destroyed, focused,
|
|
minimized, moved, resized), and a window is a small `Copy` handle, as there. The model the
|
|
window manager tiles is the one both platforms have: monitors, each with workspaces, each
|
|
with containers, each with windows.
|
|
|
|
## Why is everything the window manager needs behind one trait?
|
|
|
|
So that the window manager can be tested without a compositor. A test that needs a real
|
|
KWin has to boot a virtual machine, which takes a minute or more and needs QEMU with a
|
|
hypervisor ([vm-tests.md](vm-tests.md)); a test against an in-memory desktop takes
|
|
milliseconds and runs anywhere. The logic (which window goes where, what happens when one
|
|
closes, how a stack cycles) is tested with the second kind, and the VM tests are kept for
|
|
what only KWin can tell.
|
|
|
|
The cost is that a second implementation has to be kept honest. A fake that does what it
|
|
is told is not KWin: a real client may refuse a size, a compositor may report an event the
|
|
fake never raises, and a fake that is too permissive hides a bug that a compositor would
|
|
show. The fake is therefore written to be strict where KWin is (a command about a window
|
|
that no longer exists is an error) and to clamp where a real client does (a window with a
|
|
minimum size keeps to it).
|
|
|
|
## Why is the backend a process-wide singleton?
|
|
|
|
So that a window can be a small `Copy` value that carries only its id. A window that
|
|
had to hold a reference to its backend would not be `Copy`, and every function that
|
|
moves, focuses or hides one would take a context argument as well. That is a lot of
|
|
plumbing for a thing that never changes while the daemon runs.
|
|
|
|
The cost is a global. A test that wants a private desktop overrides it for its own thread
|
|
with `with_backend`, which restores the previous one afterwards, also on a panic.
|
|
|
|
## Why does a backend answer from a table instead of asking the compositor?
|
|
|
|
A query that waits for the compositor can wait for a long time, and the window manager
|
|
asks a lot of questions: every move of a container is followed by a look at where each
|
|
window ended up. A backend keeps a table that the compositor's events update, so a query
|
|
is a lookup. The trade-off is that the table can be a moment behind the compositor, which
|
|
is why a command returns when it has been accepted and not when the client has acted, and
|
|
why the frame a window really has is read back afterwards.
|
|
|
|
## Why does komorebi not pick the next window to focus when one closes?
|
|
|
|
Because the compositor already has. When a window is destroyed, KWin activates the window
|
|
it considers next and reports that as a focus change, which komorebi follows. If komorebi
|
|
also focused a window of its own choosing, the two would fight over the focus. komorebi
|
|
focuses the window of its focused container only when the compositor has left nothing
|
|
focused.
|
|
|
|
## Why does komorebi run a script inside KWin?
|
|
|
|
KWin has no protocol that lets another process read and set window geometry. Its scripting
|
|
API has both, and a signal for each change komorebi follows: a window added, removed,
|
|
activated, minimized, moved or resized. So the part of the backend that sees and touches
|
|
windows is a script KWin runs, the bridge, and the daemon talks to it over the session bus.
|
|
|
|
## Why does the bridge hold no logic?
|
|
|
|
So that everything the backend decides can be tested without KWin. A script runs only inside
|
|
KWin: its own tests run it against a fake of KWin's API, which is only as right as the fake,
|
|
and a test of it against KWin needs a virtual machine. The bridge reports what KWin says, as
|
|
it says it, and carries out what it is told; what an event means (is this a window komorebi
|
|
manages, has it been announced already, which id does it have) is decided by the table in
|
|
`kwin::state`, which is plain Rust that takes the bridge's JSON and is unit-tested with it.
|
|
|
|
## Why is the bridge type-checked and tested without a JavaScript build chain?
|
|
|
|
I wanted safety, tests and typing around the bridge that do not need a JavaScript build
|
|
chain. KWin loads `bridge.js` as it is, so the checks read that file and produce nothing:
|
|
|
|
- `tsc` checks it as strict TypeScript through its JSDoc comments (`checkJs`, `noEmit`),
|
|
against `kwin-api.d.ts`, a hand-written declaration of the part of KWin's scripting API the
|
|
bridge uses. KWin publishes no declarations, and the declarations are narrower than the API
|
|
where that catches a mistake: a property the bridge has no reason to set is read-only, and
|
|
`callDBus` takes one string.
|
|
- `bridge.test.js` runs under `node --test`, node's own test runner, and loads the bridge into
|
|
a `node:vm` context whose globals are a fake KWin.
|
|
- The messages of a session are JSON files that the bridge's tests and the Rust tests both
|
|
read, so neither side can change a message alone.
|
|
|
|
`tsc` and `node` come from nixpkgs, through the dev shell and the flake's
|
|
`komorebi-bridge-js` check. There is no package.json, no npm dependency and no bundler.
|
|
|
|
## Why do commands go to the bridge as the answer to a held call?
|
|
|
|
Because it was the fastest channel measured, and it costs nothing while nothing happens. Four
|
|
ways for the daemon to get commands to a script in KWin were measured in October 2026, on
|
|
KWin 6.8.1 in a VM under HVF: twenty windows retiled in one batch, twenty times, timed from
|
|
the send until every window reported its new frame.
|
|
|
|
| Channel | p50 / p95 / max |
|
|
|---|---|
|
|
| a held `Poll` call, answered when there are commands | 16 / 19 / 20 ms |
|
|
| the script polls on a 16 ms timer | 16 / 20 / 32 ms |
|
|
| the script polls on a 50 ms timer | 49 / 57 / 65 ms |
|
|
| a new script per batch (`loadScript`, `run`, `unloadScript`) | 17 / 18 / 20 ms |
|
|
|
|
A timer is as fast only at about 60 D-Bus calls a second, forever. A new script per batch is
|
|
as fast, but 7 of its 17 ms (p50) are spent in the daemon's calls to KWin. A held call is
|
|
answered as soon as there is a batch, and an idle bridge makes one call every 10 seconds.
|
|
Rounds after idle gaps of 15 seconds to 10 minutes all completed, the slowest in 49 ms.
|
|
|
|
The hold is 10 seconds because QtDBus fails a call after 25 seconds. A call that fails is
|
|
never answered (the callback of `callDBus` runs on success only), so the bridge has a
|
|
one-second watchdog that calls `Poll` again when the last call is older than the hold plus 3
|
|
seconds, which is also how it finds a daemon that has restarted.
|
|
|
|
## Why are events and the held call on two D-Bus objects?
|
|
|
|
Because events must arrive in order and a held call waits. zbus handles each method call in a
|
|
task of its own by default, and two pairs of events were seen swapped. The `Event` interface
|
|
is therefore `spawn = false`: zbus handles its calls inline, one after the other, in the
|
|
order they arrive. The price is that a handler that waits holds up every call after it, so
|
|
`Event` never waits, and `Poll`, which waits by design, is a separate object with the default
|
|
spawning.
|
|
|
|
## Why does everything cross D-Bus as a JSON string?
|
|
|
|
Because a script cannot choose the D-Bus type of what it sends. `callDBus` sends a JavaScript
|
|
number as a 32-bit integer, and a call whose arguments do not match the method's signature
|
|
fails with no error the script can see. One string argument always matches, and the daemon
|
|
parses it. The declaration of `callDBus` the bridge is type-checked against takes one string,
|
|
so `tsc` refuses anything else.
|
|
|
|
## Why does the pointer follow the focus through a KWin shortcut?
|
|
|
|
Because a KWin script cannot move the pointer, and KWin can. Its "Move Mouse to Focus" action
|
|
puts the pointer in the middle of the window that has the focus, and it can be invoked over
|
|
D-Bus, through `kglobalaccel`: in the spikes it moved the pointer from the middle of the screen
|
|
to the middle of the focused window, for two different windows. The backend asks for it once
|
|
KWin reports the window it focused as active, so the action finds the right one. The cost is
|
|
that the pointer can go to the middle of the focused window and nowhere else, which is what
|
|
komorebi asks for.
|
|
|
|
## Why does komorebi take every window out of its layout when KWin leaves the session bus?
|
|
|
|
Because nothing else will say that they have gone. When KWin goes away under a running
|
|
session (it crashed, or another KWin replaced it), its clients lose their connection and
|
|
their windows go with it, and so does the bridge, which would have reported them closing. In
|
|
the spikes KWin was killed and started again under a running daemon on the same bus, twice:
|
|
the old windows vanished without a single `removed` event. What the daemon does see is the
|
|
bus itself: `NameOwnerChanged` says that `org.kde.KWin` has lost its owner, and later that it
|
|
has a new one. So losing the name counts as every window closing, and getting it back as the
|
|
moment to load the bridge into the new KWin, which then reports its desktop as it does at
|
|
start.
|
|
|
|
## Why does komorebi take its orders on a socket?
|
|
|
|
komorebi responds to two things: what the compositor reports about windows, and the
|
|
messages it receives on its socket. It handles no keyboard or mouse input. Binding a key to
|
|
an action is the job of another program, which runs a command that writes a message on the
|
|
socket. This is the architecture that bspwm made popular on Linux and yabai on macOS, and
|
|
the one komorebi has on Windows and on macOS
|
|
([the design notes of komorebi for Mac](https://github.com/LGUG2Z/komorebi-for-mac/blob/master/docs/design.md)).
|
|
|
|
## Why is `komorebic` a separate program?
|
|
|
|
Because the daemon takes no input but its socket. `komorebic` turns a command line into one
|
|
message on the socket and, for a question, prints the answer. A key binding, a script and a
|
|
person at a terminal all drive komorebi the same way, by running `komorebic` (the design
|
|
notes of komorebi for Mac, linked above).
|
|
|
|
## Why does komorebi send notifications to subscribers?
|
|
|
|
So that a program such as a status bar can follow the window manager. The README of komorebi
|
|
for Windows demonstrates a custom status bar integrated through its notifications
|
|
([Window Manager Event Subscriptions](https://github.com/LGUG2Z/komorebi#window-manager-event-subscriptions)),
|
|
and komorebi for Mac sends the same notifications to Unix sockets, which is also how
|
|
`komorebic` talks to its daemon
|
|
([its README](https://github.com/LGUG2Z/komorebi-for-mac#window-manager-event-subscriptions)).
|
|
A subscriber here is a Unix socket, as on macOS. The named pipes that komorebi on Windows
|
|
sends notifications to are not ported, since the socket subscribers do the same.
|
|
|
|
## Why does the flake have a NixOS module?
|
|
|
|
So that komorebi runs on NixOS with one flake input and one module line. The module installs
|
|
komorebi and starts it with every Plasma session, and it adds the flake's overlay to nixpkgs
|
|
itself, so a configuration needs nothing else to find the package. Its options follow those of
|
|
the nix-darwin module of komorebi for Mac: `enable`, `package` and `logLevel`.
|
|
|
|
## Why does komorebi run as a systemd user unit?
|
|
|
|
Because a user unit covers every user: it is defined once for the machine and runs for each
|
|
user who logs in. It is wanted by `plasma-workspace.target`, so it runs in Plasma sessions and
|
|
nowhere else, and it is ordered after `plasma-core.target`, which Plasma orders after KWin's
|
|
service: the daemon exits when KWin is not on the session bus. systemd starts a daemon that fails
|
|
again.
|
|
|
|
## Why is there no binary cache?
|
|
|
|
The Komorebi license does not allow third parties to redistribute binaries. There is no public
|
|
binary cache for komorebi, and every installation builds it from source.
|
|
|
|
## Why do the VM tests run on machines built with the module?
|
|
|
|
So that the tests exercise the module. Every VM suite boots a machine that imports
|
|
`nixosModules.default` and installs komorebi through it. The headless node runs the daemon with
|
|
the module's read-only `execStart`, and the session node runs the module's unit itself. A change
|
|
to the module that breaks how komorebi is installed or started fails the VM suites, and not only
|
|
`nixos-module-eval`.
|
|
|
|
## Why does the bridge ask KWin twice for the position of a window?
|
|
|
|
KWin moves a window anywhere it is asked to, but when it resizes a window it keeps 100 pixels of
|
|
it on an output, and it does so once the client has drawn at the new size, which is after the
|
|
bridge has set the frame. The scrolling layout moves the windows of the columns out of view off
|
|
the screen and resizes them in the same command, and a terminal that rounds its size to its
|
|
cells is resized again by its client, so KWin pulls both back onto the screen. By then, setting
|
|
the position alone is a plain move, which KWin honors. The bridge remembers where it was asked
|
|
to put a window for a second, and sets the position again when the window's frame changes to
|
|
another one in that time ([PLATFORM.md](PLATFORM.md#how-clients-answer-a-frame)). Three tries
|
|
at most keep it from chasing a window that cannot go where it was asked, such as a fullscreen
|
|
one.
|
|
|
|
## Why are komorebi's workspaces KWin's virtual desktops?
|
|
|
|
Because KWin has virtual desktops, per output since KWin 6.8, and Windows and macOS have no API
|
|
for them. komorebi's own workspaces exist there for that reason: switching workspace hides
|
|
every window of the workspace being left and restores the windows of the one being entered. On
|
|
KDE that is what KWin does with its desktops, and native behavior is preferred over a
|
|
workaround (see [What is komorebi for KDE the port of?](#what-is-komorebi-for-kde-the-port-of)):
|
|
workspace `i` is desktop `i` on every monitor, and each monitor shows one at a time.
|
|
|
|
The spikes measured what KWin does with per-output desktops on a headless KWin with two
|
|
outputs: showing a desktop on one output left the other output's alone; KWin gave the focus to
|
|
a window of the desktop it switched to, by itself; and activating a window on another desktop
|
|
of its output (an application raising itself) made KWin switch that output to the window's
|
|
desktop and focus the window, by itself. So komorebi asks for a switch and follows the ones it
|
|
did not ask for, with no command in reply. A first attempt in which komorebi hid the windows
|
|
itself switched between two workspaces about 280 times a second after an application raised a
|
|
window of another workspace: KWin delivered focus events it had generated before it carried
|
|
out komorebi's commands, and komorebi took each for a new request. With KWin's desktops there
|
|
is nothing to debounce, since komorebi answers a switch with nothing.
|
|
|
|
The costs: desktops are global, so every monitor has as many as the monitor with the most
|
|
workspaces, with one name each, and they show in the pager and the overview. A window komorebi
|
|
does not manage (a dialog, say) belongs to the desktop it opened on and goes out of view with
|
|
it. The desktops komorebi created stay when it stops: by then they are the user's desktops.
|
|
|
|
## Why does komorebi edit `kwinrc` itself?
|
|
|
|
Because per-output virtual desktops are off by default, they are a setting in the user's
|
|
`kwinrc`, and the program that would set it cannot be counted on. KDE's `kwriteconfig6` was not
|
|
on the PATH of komorebi's user service in the first live run in a Plasma session, and
|
|
per-output desktops stayed off; `kreadconfig6` is not always installed either. The file is
|
|
plain ini, which komorebi changes line by line, and asks KWin to reload its configuration.
|
|
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.
|