The KDE docs were moved from komorebi-for-kde/ to docs/linux/ and the sources, nix files, scripts and schemas to komorebi-linux/, nix/linux/, scripts/linux/ and schemas/linux/, so every relative link that left docs/ (to sources, nix files, scripts, schemas, .envrc, rust-toolchain.toml, the old CI file) was broken, and the text named the old paths. Links were rewritten mechanically by resolving each broken target against the old location and mapping it to the new one; link texts and code-span paths were updated to the new paths (komorebi/src -> komorebi-linux/src, nix/ -> nix/linux/, scripts/ -> scripts/linux/, docs/ -> docs/linux/, schema.json -> schemas/linux/schema.json). The crate list of the README names komorebi-linux, komorebi-daemon and komorebi-version; `cargo install --path komorebi` becomes komorebi-daemon; the comparison paragraph of CONTRIBUTING.md says compare-gen.py is stale (it looks for the old sibling trees). URLs of the old GitHub repositories are left as they are (a release decision). COMPARE.md is left as it is (already marked stale). Verified: a link checker over every markdown file (outside cli/, nix/, tools/, old-ci) reports no broken link in docs/linux. Nothing was built.
59 KiB
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, and both were read for
what to adapt. I wanted to keep things as close to komorebi for Windows and for Mac as
possible, so that one configuration file is largely portable across all the platforms, and to
drop the hacks that Windows or macOS needed and Linux does not, relying on what KDE and
Wayland do natively. 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. What the three
programs have in common and where they differ is in COMPARE.md.
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); 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:
tscchecks it as strict TypeScript through its JSDoc comments (checkJs,noEmit), againstkwin-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, andcallDBustakes one string.bridge.test.jsruns undernode --test, node's own test runner, and loads the bridge into anode:vmcontext 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).
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),
and komorebi for Mac sends the same notifications to Unix sockets, which is also how
komorebic talks to its daemon
(its README).
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, so the flake builds it from source on each machine that installs it. The releases of this repository are the only binaries (why).
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 to the edge of the work area, where a few pixels of them are on the screen, and resizes the ones that change width 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). 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?):
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.
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. Spike 3 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 reading of KWin's sources (6.8.1) is in
PLATFORM.md. 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), 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, 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.
Why is the monocle container not one of the containers?
Because a layout gives every container a tile, and the monocle container is the one that has the
whole work area. Workspace::new_monocle_container takes the
focused container out of the workspace's containers and remembers its index, and
Workspace::update puts the window of the monocle container on
the work area and gives the other containers no tile. The container is taken out and put back
with the plain remove and insert of the ring, and not the ones that respect locked containers:
the comments of the code say that a monocle, like a maximize, takes over the whole screen before
it is reinserted at the same index, which is what respecting the indexes of locked containers
needs.
The container keeps its resize adjustment, "because when this container is inevitably
reintegrated, it would be weird if it doesn't go back to the dimensions it had before", and for
the same reason update does not shorten the list of adjustments while there is a monocle
container: with two containers, one of them in monocle, the list would be cut to one, and the
adjustment of the container in monocle would be lost when it came back (the comments in
new_monocle_container and update).
Why does a maximized window leave the layout, instead of being given the work area?
Because KWin has a maximize of its own, and komorebi asks for it. The window gets the work area of
its output with the panels left alone, and a restore goes back to where the window was
(see the comment in the bridge where it carries the command out). It is the work
area and not the padded one that Workspace::update maximizes
a window to ("the compositor's own maximize, which is the work area and not the padded one"), so
the window cannot be a container: a layout would give it a tile, less the padding. It is held in
maximized_window instead, with the index it came from, and when it is restored it goes back to
that index in a container of its own. It is restored before it is tiled again, because "it is its
tile's size that it has to get back, not the compositor's idea of its old size" (the comment in
reintegrate_maximized_window).
Why does one komorebi.json work on every platform?
Because a file written for one platform should load on the others, and a key that means
nothing on one of them is no reason to refuse the file. StaticConfig has the keys of komorebi
for Windows and for Mac that mean something on KDE Plasma, and serde skips a key it does not
know, so a key that does nothing here is accepted and ignored. The fixtures of config_parity
(spike 8) are the evidence: the example of komorebi for Windows, a file with every key that only
Windows has, a real file of komorebi for Mac and this repository's example all load and read
back, and the test checks that the keys the serializer drops are the Windows-only ones
(WINDOWS_ONLY) and no others. Where komorebi for Mac has no key that KDE needs
(focus_follows_mouse, transparency), StaticConfig has the one komorebi for Windows has.
COMPARE.md lists the keys of the three programs.
Why does komorebi reload its configuration when the file changes?
It does what komorebi for Mac does: it watches komorebi.json and reloads it after about two
seconds. On Windows, watch-configuration is about the legacy AutoHotkey and PowerShell files,
which do not exist here. The watcher is hotwatch, whose default is to wait two seconds for the events of an
editor to settle. An editor either writes a file in place or replaces it with a new one, so the
daemon reloads on a modification and on a removal, and the test that follows a real daemon
through both of them is the_daemon_follows_the_file_it_is_given_when_the_file_changes.
Why is an applications file that is not there skipped, and one that is broken an error?
Because one komorebi.json is meant to work on every platform, and the file it points to may be
one that another platform has: a configuration written for macOS names applications.mac.json,
which a KDE desktop does not have. The daemon warns and goes on. A file that exists and is not an
applications.json is a mistake in something the user wrote, and is an error.
Why is a key komorebi does not know a warning, and a value it cannot read an error?
Because one komorebi.json is meant to work on every platform, and a key that komorebi for KDE
does not read may be one that another platform does. serde drops a key the types do not have, so
loading never fails because of one, and komorebic check does not fail for one either: the keys
only Windows or macOS reads are listed once as a note, and only a key that no platform has is a
warning, with the nearest key that exists, since it is most likely a typo and komorebi will not
have done what was meant. A value of the wrong kind is a different matter: the daemon does not
read the file (a daemon that is running keeps what it had), so the check shows the line and exits
with status 1. A workspace that is only a name is a warning too: it is not tiled, which is easy to
do without meaning to.
Why is a daemon that runs without its script in KWin an error, and one that does not run a note?
A daemon without its script in KWin cannot see a window, so it tiles nothing while it runs: that
is an error, the one finding of the desktop check that says a daemon which is running is broken.
A daemon that is not running is not broken, so it is a note, with the command that starts it. A
script that is still in KWin when its daemon is gone is a warning, since starting the daemon
replaces it. The check tells which of the three it is by connecting to the daemon's socket and by
asking KWin whether it has loaded komorebi-bridge.
Why does nix/linux/komorebi-options.nix start with the hash of the schema?
Because the check that the options are fresh cannot make them again. schemars-nixgen makes
nix/linux/komorebi-options.nix from schemas/linux/schema.json, and it is not in the dev shell or in CI (it is
installed with cargo install). just nixgen writes the hash of schemas/linux/schema.json as the first line
of the file, and the check schema-fresh compares that with the hash of the committed schema, and
the schema with what komorebic static-config-schema prints. A change to a key that leaves the
schema or the options behind fails the check, with no tool but the build.
Why does komorebic write an autostart entry, as well as the module having a unit?
The module is NixOS, and the daemon runs on other distributions too. An XDG autostart entry is
what a Plasma session runs at login for a program that is not part of it, so
komorebic enable-autostart writes one, with the komorebi.json it is given if any. The entry is
ordered after plasma-workspace (X-KDE-autostart-after), as the module's unit is ordered after
plasma-core.target: the daemon exits when KWin is not on the session bus. The entry and the
unit do not go together, since the session bus gives its name to one komorebi.
Why does the flake set the version of KDE Rounded Corners?
Because 0.10.0 draws its outline in the wrong place on KWin 6.8, the oldest KWin komorebi
supports: about 24 px too low, across the title bar. 0.11.0 is the release that handles the
texture coordinates KWin 6.8 inverted, so
nix/linux/kde-rounded-corners.nix sets the version and the
source of the nixpkgs package to it (PLATFORM.md). nixpkgs d3b13ad
(2026-10-24) has 0.10.0 and f05535a (2026-10-27) has 0.11.0, so at time of writing
(2026-10-28) setting them changes nothing. The package still sets them, because the patch that
gives each window its own color is written for the 0.11.0 source.
Why are the effects built disabled by default?
So that they cannot load with their own defaults before komorebi configures them. KWin loads
an effect that is enabled by default as soon as it starts, with the settings the effect has
then, and KDE Rounded Corners at its defaults draws a one pixel black outline over the title
bar of every window. Built with EnabledByDefault false, an effect is loaded only when
something asks for it with loadEffect, which can come after its settings are written. That
KWin then starts without the effect and loads it when asked was measured before the package
was written (PLATFORM.md). Geometry Change ships disabled by
default, and its check asserts it, so that an update of the effect cannot change it unseen.
Why does the compositor draw the borders, and not komorebi?
Because the compositor has the frames, the corners and the shadows of the windows, and komorebi
has none of them. komorebi decides what the windows look like and describes it to the backend as a
Look (Backend::apply_look); on KWin the effect KDE
Rounded Corners draws it, and komorebi writes the effect's settings. The same code that decides the
look then runs against the fake desktop, which records the look it was asked for.
Why is the border the outer outline of the effect?
Because the effect's own defaults draw a black outline one pixel thick inside every window, over its title bar and contents, which is what an unconfigured install looks like. So komorebi sets every inner outline to 0 and uses the outer outline, which is drawn outside the window, in the gap the layout leaves between tiles. The effect draws the outer outline only in the shadow of the window (its shader stops where the window's texture is transparent), so it is at most 20 px thick, and a window that has no shadow has no border. komorebi does not take the title bar off a window to change that: removing it also removed the shadow, and the border with it, and Plasma 6.8 gives a window without a shadow one of its own (PLATFORM.md).
Why does the layout leave room for the border only while it is drawn?
Because the border is outside the window: without room for it, it would fall on the window next
to it. And because komorebi cannot know that a border is drawn. The effect may not be installed
(KWin finds effects when it starts), or borders may be off, so the backend answers whether
apply_look drew one, and the layout leaves room only after a yes. A compositor that draws no
border takes none.
Why does komorebi ask the effect to read its settings again after it loads it?
Because KWin keeps the copy of kwinrc it read when it started, and an effect reads its settings
from that copy, also one loaded a moment ago. Writing the file and loading the effect leaves the
effect with the settings the file had at the start of the session. reconfigureEffect makes it
read them again, so komorebi calls it every time, after loading too.
Why does the border effect take a color for each window?
Because komorebi colors a window by what it is to the layout (the focused window of each monitor, a stack, a monocle, a floating window, a locked container), and the stock effect knows only "the active window" and "the others". komorebi's build of KDE Rounded Corners takes a color for each window over D-Bus, through KWin's name (PLATFORM.md). Without that build every kind falls back to the two colors of the look, and komorebi says so once in its log.
Why does komorebi animate nothing itself?
Because the compositor draws the windows: an animation komorebi made would be a series of frames
asked of KWin, each a command, where the Geometry Change effect animates what is painted, in the
compositor, at the refresh rate of the screen. komorebi tells the effect how long an animation
lasts (border_manager::current_look). That is why
animation-fps is accepted and does nothing, and why the frame KWin reports for a window is the
final one from the start of the animation: the effect moves what is painted and not the window
(PLATFORM.md).
Why is cross-fading off?
Because cross-fading the old contents of a window into the new while it changes size is the effect's default, and it costs a redraw of both. A window going to its place is a move and a resize, and what the window shows does not change.
Why are komorebi's animation styles Qt's easing curves?
Because KWin animates with QEasingCurve, and a style is a curve KWin has when it is Qt's name
without the "Ease": EaseInOutSine is InOutSine
(QtCurve::qt_curve). A custom cubic Bézier is not
one of Qt's curves, and is drawn as an ease out, the kind of curve a window moving to its place
wants. The stock Geometry Change has one curve, an exponential ease out, so komorebi's build of
it reads a Curve setting (PLATFORM.md).
Why does the window manager keep the opacity it gave each window?
So that the backend is told only what changed. transparency_manager::apply
(transparency_manager.rs) is called after every
event and every message, and most of them change no window's opacity: the window manager keeps
the opacity it gave each window that is not opaque (WindowManager::opacity_applied), sends the
difference, makes a window opaque again when it should no longer be see-through (and only if it is
still there), and makes every one opaque when it stops.
Why is focus follows mouse KWin's own focus policy?
Because KWin has one, and gives the focus to the window under the pointer by itself. komorebi
asks for it by changing the user's FocusPolicy and follows the focus, as it follows any change
of focus, and puts the policy back when focus follows mouse is turned off and when it exits
(PLATFORM.md). Both implementations komorebi for Windows
names, Windows and Komorebi, ask KWin for the same thing. Upstream marks the Windows key
end-of-life in favor of masir, a separate program. The code also sets DelayFocusInterval to 0,
which is KWin's setting for no delay between the pointer entering a window and the window being
given the focus; why 0 and not the delay KWin has by default is not written down, and needs a
reason.
Why is a palette kept by the hash of its picture?
Because the name of a picture alone does not say which picture it is: two folders can each have a
wallpaper.png, and one wallpaper can be painted over. A palette takes seconds to make for a
large picture, so it is kept, in the user's cache directory (~/.cache/komorebi), where it
outlives a login; the runtime directory, where the socket is, does not. The file is named after the
picture, a hash of its path, size and time of last change, and the variant. A palette that cannot
be kept is made again the next time. komorebi for Windows keeps them by file name alone.
Why does komorebi set the wallpaper when a workspace comes into view?
Because Plasma keeps one wallpaper for each screen, not for each virtual desktop, and a workspace is a virtual desktop. The shell shows what it was last told, whatever desktop is shown, so komorebi tells it again as the workspace changes, as komorebi does on Windows and macOS. It does so wherever a workspace comes into view (its own switches, a pager's, an application raising a window of another workspace, and the start), since all of them change what the screen should show. The picture fills the screen, as komorebi's wallpapers do on Windows.
Why does komorebi ask the shell what the screen shows before it sets a wallpaper?
Because the workspace's wallpaper is asked for every time the workspace comes into view, and the user may have changed the wallpaper by hand since: a wallpaper that is not the one komorebi wants is put back, and one that already is, in the same plugin and fill mode, is not touched, so a switch between two workspaces with the same picture changes nothing on the screen.
Why is the wallpaper set in the background, and tried again?
Because the shell is another process, which may be slow, and may still be starting when komorebi is (a session that has just logged in). Switching workspaces never waits for it: the backend notes the wish for the output and a task on the runtime does the asking, so the request that runs last finds the newest wish. A shell that is not there is asked again for about half a minute, with longer waits, and then komorebi says once that it leaves wallpapers alone until the shell answers.
Why does a wallpaper hand its theme to the borders by a call, and not by a message?
Because on Windows the theme of a wallpaper is sent to komorebi's own socket, and a message to itself does nothing a call does not do. The call is made before the workspace is shown, so the colors are the borders' by the time the first window of the workspace is given one.
Why are the effects patched?
Because the stock effects lack what komorebi needs of them. Each patch leaves the effect working without komorebi, with the settings the stock effect has:
| Effect | Patched for | Why |
|---|---|---|
| KDE Rounded Corners | a color for each window, set over D-Bus; drawing a window again when its opacity changes | komorebi colors a window by what it is to the layout (the focused window of each monitor, a stack, a monocle, a floating window, a locked container), and the stock effect has one color for the active window and one for the others. It also draws a window from a texture that has the window's opacity in it, made again only when the contents change or the window is resized, so a change of opacity alone would not show |
| Geometry Change | a Curve setting, the name of one of Qt's easing curves |
komorebi's animation.style is an easing curve by name, and its named styles are Qt's curves with "Ease" in front (EaseInOutSine is Qt's InOutSine); the stock effect has one fixed curve, an exponential ease out, and no setting for it |
The patches are derivative works of the effects and are under the effects' license, the GNU General Public License version 3 (PLATFORM.md).
Why does the end of a drag carry the position of the pointer?
A window dragged onto another tile swaps places with the window there, and one dragged onto another monitor goes to that monitor. The window manager finds the tile and the monitor by asking the backend where the pointer is. Only the bridge can read the pointer, so the KWin backend asks it through the held call, and returns the last position the table has when no answer comes within 250 ms. The end of a drag is the moment the window manager asks, and the event that reports it brings the position of the pointer with it, so the last position the table has is the one that counts.
Why is a drag of an edge measured from where the window was when it began?
A window is not always on its tile. A client that sizes itself to a grid, such as a terminal that keeps to whole cells of its font, is a few pixels short of it, and a window with a minimum size can be larger. Measured against the tile, every drag of such a window would look as if it had moved the other edges too, and they would be resized as well. The frame the window had when the drag began is what the user had under the pointer, so the drag is measured from it. KWin reports the window's frame all through a drag, so the table keeps the frame of the start apart from it, and the window manager reads that one when it handles the start.
Why is the end of a resize the frame the user asked for and not the window's frame?
KWin changes the window's frame only once the client has drawn at the new size, so when the user lets go the frame can be short of where they took the edge (PLATFORM.md). The frame the last step of the drag asked for is where the user took it. The table keeps it apart from the window's frame, because the window manager does not handle the end of the drag at once: it may be busy with an earlier event, and the frame event KWin sends a millisecond after the end changes the frame in the table before the window manager reads it. The end of the drag has to survive that, so it is not the window's frame. The frame events go on updating the window's frame, and the retile that ends the drag puts the window on its tile. A move changes no size, so the end of a move is the frame.
Why is every step of a drag worked out from where the drag began?
A step says where the user has the edge now, so the drag so far is the distance from the start, not something to add to the step before. Starting each step, and the end, from the layout as it was when the user took hold makes the result of a drag the same with its steps as without them, and a drag that ends where it began puts the neighbors back where they were.
Why is the window whose edge is being dragged not moved during the drag?
It is the user's until they let go: KWin moves its edge with the pointer, and komorebi puts it on
its tile when the drag ends. A step tiles the workspace to move the neighbors, and so can anything
else that happens during the drag, such as a window that opens. The guard is in
Window::set_position, for the windows being resized, so that whatever tiles the workspace leaves
the dragged window alone.
Why are the steps of a drag thinned out?
The pointer makes far more steps than anyone needs. The bridge sends one every 16 ms at most, the newest, and a single-shot timer sends the last one of a burst, so the edge's final place is not lost. Each step says where the edge is now, so one waiting in the window manager's channel is enough: the window manager asks the window where its edges are when it gets to the step, and that is the newest.
Why does the bridge look at the outputs once a second?
Because KWin has no signal for the room a panel leaves, only for the outputs themselves. A
panel that starts, grows, moves or hides changes the work area of an output and raises nothing
the script can connect to, so the bridge reads the work area of every output again when
screensChanged is raised and when a dock window opens, closes or moves, and once a second in
its watchdog. Two of the moments are not enough alone: komorebi usually starts before the panel
does, and a panel is laid out after the resolution it follows, so the area at the moment of the
signal is not the final one. The bridge keeps the outputs it last sent and sends them only when
they are not the same, so a second that changed nothing sends nothing. The cost is that a panel
that changes its room with no dock window event is found up to a second late, and that the
bridge does a little work every second.
Why does komorebi take the windows of an unplugged monitor over itself?
Because nothing else can tile them. When an output goes, KWin takes its windows to the outputs that are left and puts them where it likes. komorebi has them in the containers of the monitor that went, whose tiles are on a screen that is gone, so the windows sit untiled wherever KWin put them. komorebi moves each container to the monitor that now holds the middle of its window, and tiles the monitors that changed. It keeps the monitor that went, with its workspaces and settings, so that an output that is plugged in again gets them back: the windows the monitor had are tiled elsewhere by then, and it comes back without them.
Why does komorebi look at every window again a moment after the monitors change?
Because KWin moves windows about for a moment after the outputs change, so what it reports first is not where the windows end up. An output that went takes its windows to another, one that is plugged back may take them again, and a change of scale or resolution changes where the middle of a window is. The monitor reconciliator therefore waits 1.5 seconds after a change of the monitors (a burst of changes makes one wait) and then finds every container whose window is on another monitor than the one komorebi has it on, and follows it there. A window that KWin reports on another output is handled the same way, looking at every window and not only that one, since when one has moved others may have too. komorebi tiles at once the windows it knows about when the monitors change, such as those of an output that went; the ones KWin moves afterwards are tiled up to 1.5 seconds later.
Why does komorebi look at the desktop again when the machine wakes and the screen is unlocked?
Because nothing komorebi asked for while the screen was locked or the machine asleep was done.
In that time KWin sends its clients no frame callbacks, so a window that was asked to move did
not, a window that something else moved stayed where it was moved, and the monitors can come
back in another order or at another size. Neither an unlock nor a wake is an event about a
window, so komorebi listens for the two signals that say it happened, logind's PrepareForSleep
and the lock screen's ActiveChanged, and then reads the monitors, finds where every window is,
and puts each in its tile. It waits first: 0.6 seconds after an unlock, because the lock screen
goes before the windows can be seen, and 2.5 seconds after a wake, because the outputs are found
again one after another and the compositor moves windows about as they are. Going to sleep and
locking the screen change nothing, so komorebi does nothing for them.
Why does the bridge compare the active window with the last one it announced?
Because after the screen is unlocked the window that is active again is not always announced, and komorebi's idea of the focus would stay the one it had. Once a second the bridge compares the window KWin has active with the one it last announced, and announces the active one when they differ. It costs a comparison of two ids a second.
Why is a window the user maximizes treated like one komorebi maximized?
Because the compositor reports both the same way, and komorebi has to follow it. The comment on the
MaximizeChange event says that komorebi's own toggle-maximize "does this too and finds the window
already as it asked, but the user's title bar does it without komorebi's knowing". A window that
stayed a tile would be asked to move at the next retile: the code that reads the windows that are
open when komorebi starts says a window that is already maximized is "the maximized window, not a tile
that would be asked to leave the maximized state". So
WindowManager::adopt_maximize_change takes the window out of
the layout as toggle-maximize makes a window leave it, and what is asked of the layout does
nothing "until it is restored, by toggle-maximize or by the user" (the comment in
process_command). Because the report is the same either way, one comparison serves both: a window
that is, or is not, already the workspace's maximized window changes nothing.
Why does komorebi leave a fullscreen window alone, and tile the workspace when it comes back?
Because the window covers the monitor on purpose. The comment in
Window::set_position says that "moving it would take it out of
fullscreen, or be ignored", and that "it goes back to its tile when it leaves fullscreen". The
window stays in its container, so the other windows are tiled as if it were not fullscreen, and
every move the window manager makes goes through set_position, which is where the window is
skipped. When the window comes back, "KWin gives it the frame it had before, which may not be the
tile it has now" (the comment in handle_event), so the workspace is tiled again. The bridge's
second request for a position gives up after three tries for the same reason: so that a window that
cannot go where it was asked, "a fullscreen one, say", is left alone rather than chased (the comment
on settle in bridge.js).
Why does initial_window_placement_rules take a number as well as text?
Because the documentation of the setting shows a number ("initial_window_placement_rules": 3),
and a JSON object's keys can only be text: the doc comment on the Deserialize of
PlacementTarget says both. The place is read from "Primary",
"3" and 3, so the form the page shows works as well as the form a map's keys force. In komorebi
for Windows, at its commit 83c4e11d, the deserializer reads only text, so the number its page shows
is refused. The page of this repository says which
forms are read.
The JSON schema of the type is written by hand, so that it says the same. The one schemars
derives for the enum is an object with a Placement or an Index key, which the comment on the
JsonSchema impl of PlacementTarget calls "not what
Serialize and Deserialize read and write". The schema schemas/linux/schema.json has says a name, a number,
or text that is a number, which is also what the module's typed config takes.
Why are the key binding files read by parsers written for komorebi?
All three formats, skhd's, whkd's and sxhkd's, are read by parsers written by hand in
komorebi-hotkeys. For whkd there is a Rust parser, whkd-parser, but it requires a
.shell pwsh|powershell|cmd line, panics in Shell::from on any other shell, and its
per-application bindings match Windows process names. It is under the same license as
komorebi, so it stays usable as a dev-dependency: a test reads every whkdrc of the fixture
corpus with both parsers and compares the bindings. For skhd and sxhkd the reason is not
recorded; this entry needs one.
What is not plain syntax is expanded while the file is read: sxhkd's braces become one binding
per alternative, and skhd's .define templates and application groups are replaced where
they are used, so the model has only expanded bindings in it.
Why are some bindings shortcut files, and the others the engine's?
A KDE global shortcut can be a .desktop file that KDE reads by itself. Such a static
shortcut is robust: it shows in System Settings, where it can be changed, and it survives a
komorebi process that has died. But it is one combo that runs one command: it cannot be in a
mode, wait for the second key of a chord, fire on release, or do something else in each
application. So a binding is a static shortcut when it is in the default mode, a single combo,
unscoped and a press binding, and the file declares no modes; everything else goes through
the engine, in which komorebi registers the keys itself and decides what each press does. A
blacklist and a pause binding apply to every binding, so a file with either is the engine's
too.
Why does the engine handle every binding by default?
A static shortcut starts its command later than the engine is told of the key: on KWin 6.8.1
(in a virtual machine, on 2026-10-27) a static shortcut ran its command 165 to 436 ms after the
key, 233 ms at the median, while the engine was told of the key 2.5 ms after it, at the
median. So the default is PlanMode::Engine, and PlanMode::Auto and PlanMode::Static stay
selectable.
The cost is the robustness of static shortcuts: the engine works only while komorebi runs, and a komorebi process that was killed leaves its keys registered and dead until they are released.
Which of the two is the default is still open for a decision. PlanMode::default() is the
one place it is set, and komorebic hotkeys compile and the daemon's --keybindings-mode
take their default from there; the NixOS module's keybindings.mode repeats it, "engine".
Why does the engine grab and give back keys, instead of using KDE's own key sequences?
Because KDE's do not work for this, as measured on KWin 6.8.1 (2026-10-27). kglobalacceld
accepts a sequence of keys for one action (Meta+A, B), but the action never fired: the
second key reached the focused application instead. What does work is grabbing and giving
back single combos: a combo registered with setShortcutKeys is swallowed and reported,
also a bare key with no modifier, and after setInactive the same key reaches the
application again. Sixty such calls, sent without waiting for the answers, were all answered
in 2.4 ms.
So the engine realizes everything with one set of active combos. A chord chain is a combo that, when pressed, makes only the combos that can come next (and Escape) active, until one is pressed or the chord timeout passes. A mode is a different set. A per-application binding is in the set only while the focused application has an action for it, so that elsewhere the key reaches the application. After every event the engine computes the set it wants and returns the difference.
A held key sends one pressed signal, then repeated ones, then one released signal. The engine acts on the pressed signal only, so holding a key runs its binding once.
The engine itself does no I/O: it returns what to do, and the daemon does it. This entry needs the reason for that split.
Why does the unit release the keys after the daemon stops?
Because nothing else does when a daemon dies. kglobalaccel keeps the actions of a client
that was killed, and their keys with them: on KWin 6.8.1 (2026-10-27) a killed client's key
stayed swallowed, and its signal was sent to nobody, until the action was unregistered. A
daemon that stops on its own unregisters its keys, but one that crashes or is killed cannot,
and the next start may never come. So the NixOS module's unit runs komorebic hotkeys release after the daemon stops, however it stops (ExecStopPost), and a start takes over
what is still registered.
Why does a weekly job build against the tip of nixpkgs?
Because KWin and Plasma come from nixpkgs, and the flake follows the tip of nixpkgs-unstable
rather than a release branch, so a new KWin or Plasma arrives whenever flake.lock moves.
Only the VM tests can notice what a new KWin changes, and the pull request that moves the
lock every week runs the slow tier only when it is labeled. The canary job builds every
check, both VM tiers included, against the tip every week, without changing the repository,
and opens an issue when one fails, so that a break is known before the lock moves to it. A
break is fixed in a commit of its own rather than by pinning nixpkgs back.
Why does the module check the version of KWin?
KWin 6.8 is the oldest KWin komorebi supports, because komorebi relies on the per-output virtual desktops it introduced: the bridge reads the work area of each output for the desktop that output shows. A NixOS system with an older KWin fails at evaluation, with a message that names both versions.
The repository develops against the tip of nixpkgs-unstable, a weekly workflow moves
flake.lock to the tip, and a user's own nixpkgs can be newer still, so a system can have a
KWin that no VM suite has run against. Next to the floor,
nix/linux/supported-kwin.nix records testedMax, the newest KWin
every suite has passed against, and the module warns above it.
services.komorebi.kwin.untestedIsError turns the warning into an error. The two versions are
written down in that one file, which the module imports and anything else can read with nix eval.
Why is there a guest for UTM?
Because the QEMU in nixpkgs cannot give a guest a GPU on a Mac. Its virtio-gpu is 2D only, so
KWin composites on the guest's CPUs with llvmpipe, and KRDP encodes every frame on the same CPUs,
which is slow to use (it was not benchmarked). That QEMU cannot be rebuilt with GL from nixpkgs on
a Mac: virglrenderer and libepoxy build on aarch64-darwin, and ANGLE does not. krunkit 1.2.1
accepts a virtio-gpu device and then ignores it. UTM's QEMU has virtio-gpu-gl, which renders
the guest's OpenGL on the Mac's GPU through VirGL and shows the guest in a window of its own, with
no RDP in between.
The disk is made with nixos-install from a running guest, and not with nixpkgs' disk image
builders: those boot a virtual machine as part of the build, so they need a builder with KVM, and
a Linux builder that is itself a virtual machine on the Mac, as darwin.linux-builder is, has no
nested KVM. nixos-install is a plain program, in a guest that runs at the speed of the Mac's own
virtualization.
Why does run-kde start the guest in QEMU with KVM, and not in Hyper-V?
The guest is started from a NixOS machine in WSL 2 that has this repository checked out: that is the
machine I named (Windows 11, an AMD CPU, the latest NixOS-WSL, x86_64), and the command is meant to
need nothing else set up. The flake already builds the Plasma guests as a QEMU runner that boots
the system from the host's Nix store, and the VM tests boot the same guests under QEMU with KVM,
so the command starts what CI boots. KVM is what WSL 2 offers inside its own virtual machine when
nested virtualization is on. The command needs it, and refuses to start without it, since Plasma
under software emulation is too slow to try anything; --tcg allows it anyway.
Why not Hyper-V, whose virtual machines Windows itself runs? Nobody wrote down a reason, and this entry does not make one up. What is known is that a Hyper-V guest would be another artifact, a disk image that Windows' hypervisor boots, and that the repository builds none. The reason for choosing QEMU over it still needs to be written down.
Why is the QEMU window the first way to see the guest, and RDP the second?
I decided that the primary view is the QEMU window through WSLg, and that RDP from
Windows (mstsc) comes second. The reason was not written down. What bears on it is known: the
guest's RDP server, KRDP, sends H.264 video only, encoded in software on the guest's CPUs when there
is no VAAPI device, FreeRDP built with OpenH264 shows nothing of it, and nobody has tried Windows'
client against it (PLATFORM.md). A window of QEMU has no video
encoder between the guest and the screen. How fast the window is through WSLg has not been
measured: the plan has the probe's number decide which is the default.
Why does the guest use the GPU wherever the host has one?
Because I want GPU accel where it's available on the host. So the GPU path is part of the design and
not an opt-in: run-kde looks for it (/dev/dxg on WSL 2, a render node elsewhere, and a host
OpenGL that is not llvmpipe) and starts the guest with virtio-vga-gl and QEMU's GTK window with
OpenGL, which gives KWin in the guest real OpenGL through VirGL. When the host has none, it starts the
guest with a software virtio-vga and says why. --gpu auto|on|off overrides the choice, and KVM is
needed either way.
The display device is not part of the guest. The runner of nixpkgs would fix it when the guest is
built, so the demo guest leaves it out, and run-kde gives the runner the device when it starts
the guest: one closure and one disk serve both paths.
Why is the password made for each user, and shared by a directory?
A guest's closure is in the Nix store of whoever builds it, which every user of that machine can
read, so a password or a key in it would be known to all of them, and would be the same for everyone
who runs the demo. The Plasma guests carry no key and no password already
(utm.md). run-kde makes a random RDP password and an ssh key pair when it makes the state
directory, in files that only the user can read, and shares that directory with the guest, which
takes the two files when it boots. Why a shared directory and not another way to hand the guest a
secret was not written down; the guests already get the repository that way.
The guest copies the files as root, and does not read them where they are, because the user of the host who owns the directory need not be the guest's user: with another owner, and the mode of 0700 the directory has, the guest's user cannot read it.
Why are binaries published only by this repository?
The Komorebi License allows anyone to distribute the source code, with changes, but not binaries built from it. So the releases of this repository, which CI builds, are the only binaries, and the flake has no public binary cache: nix builds komorebi from source.
The release binaries are built by nix like every other output of the flake, then pointed at the system's dynamic loader with no library path. As nix builds it, a binary loads glibc from the store paths of the nixpkgs revision it was built with, which a machine without that revision in its store does not have.