Commit Graph
44 Commits
Author SHA1 Message Date
LGUG2Z c59f5dddd0 build(nix): scale of 1.5 on every Linux host, and what the suites take on WSL 2
latency measured a p95 of 128.9 ms against its 100 ms times scale on the
WSL2 machine, under nested KVM with nothing else running, where x86-1 had
passed it at scale 1. Every Linux host is no faster than a Mac to the
suites that time the daemon, so scale is 1.5 on all of them: latency
passes at it with a p95 of 132.4 ms against 150 ms. The run-to-run
difference (p50 46.0 ms, then 70.0) is as large as the margin left, which
PLATFORM.md says.

PLATFORM.md records the wall times of the suites on this machine. All 33
were run there after the library was split: 32 passed at scale 1, and
latency is the one that failed. The other 32 were not rerun at 1.5, which
changes their derivations.
2026-10-30 12:02:55 -07:00
LGUG2Z 1ee2405e44 docs(repo): trying komorebi in a virtual machine
The guide for the one command: docs/trying-it.md says what a machine needs to
run the demo guest, with the lines of .wslconfig and of the NixOS-WSL
configuration for Windows 11, how to check it with `run-kde --doctor`, what the
command does and shows, the key bindings to try, how the GPU path is chosen, how
to open Windows' own RDP client, how long the first run takes (measured on a
machine with 32 cores and an empty store, by number of cores, with an estimate
for a WSL 2 machine and its caveats), how to start again, how to work on
komorebi in the guest, and what to do when something is wrong, by symptom. It
ends with what was run and what was not: nothing has been run on WSL 2 yet. The
README has a section and a link, and the user guide index a line.

DESIGN.md has four entries. Two record what I decided and want: the QEMU window
first and RDP second, and the GPU wherever the host has one. Two say that a
reason was not written down, for QEMU with KVM and not Hyper-V and for sharing
the password by a directory, and what is known that bears on each; the password
entry also says why a password is made for each user. PLATFORM.md has a section
for WSL 2 with what the command expects, a table of the probe results still to
be recorded (KVM, display, GPU, RDP, resources, virtiofs), and what is known of
virtiofsd without WSL 2.
2026-10-28 14:20:08 -07:00
LGUG2Z 0e8e25957c build(nix): the demo guest as a VM suite
The demo guest is what the command `run-kde` starts, so it should not stop
working between the times somebody runs it by hand. The suite `run-kde` puts the
guest module, nix/dev/run-kde.nix, on the session node, as the rdp suite does
with krdp.nix, and checks what only a booted session can say: the demo opens two
Konsole windows, Kate and Dolphin and komorebi tiles them clear of the panel,
the cheat sheet is a window that is not a tile, what it shows of `komorebic
check` has no error, and KWin says what it draws with.

The node makes the directory the host shares as the guest finds it once it has
mounted it: owned by a user the guest does not have, with a password and a
public key in it. The suite checks that the guest takes them as root before the
login, that ssh lets in that key and no other, that KRDP starts by itself and
listens, and that a client with the password is let in and shows the display.

The client of those RDP checks moves from the rdp suite into the library as
RdpClient, with unit tests against the fake machine, so that both suites run the
same checks; the machine protocol gains copy_from_machine for it.
2026-10-28 14:20:08 -07:00
LGUG2Z 041dd35224 build(nix): the demo guest
A person who wants to see komorebi tile windows should not have to configure a
machine first. `run-kde-x86_64` and `run-kde-aarch64` are the session guest with
a demo on it: the NixOS module configures komorebi from a typed `config` (eight
workspaces, borders, animation) and the sample skhd bindings, and at login the
guest opens two Konsole windows, Kate and Dolphin, which komorebi tiles, and a
cheat sheet window that it leaves alone, with the key bindings, `komorebic`
commands to try, what KWin draws with, and the output of `komorebic check`.

The closure is the same for everyone. No password or key is in it: the host
shares a directory, which the guest mounts at /run/run-kde, and a unit copies
the RDP password and the ssh public key from it, since the files can belong to a
user of the host who is not the guest user. krdp.nix reads the password from the
file that `komorebi.krdp.passwordFile` names when it exists. The display device,
the port forwards, the memory and whether to insist on KVM are left to the
environment of the runner, so that one closure and one disk run with a software
virtio-vga or with a virtio-vga-gl that renders on the host GPU. The module is a
separate file that does not import plasma-vm.nix, so that a VM suite can put it
on the session node.

plasma-guests-eval asserts the same of the demo guests.
2026-10-28 13:27:11 -07:00
LGUG2Z 9b60a0020d chore(scripts): probe a WSL2 NixOS VM for KVM, WSLg and RDP
The port can only be tried on a Windows machine, through a NixOS virtual machine
in WSL2, which the machines this history is built on do not have. What such a
machine can do for a Plasma guest is not known until something runs there, and
every guess about it is a risk for what comes next. wsl2-probe is that
something: one command, nix run .#wsl2-probe, that answers in about two minutes
what the guest needs to know and prints, for each answer that is not a yes, the
exact change: the .wslconfig lines, the NixOS option, the PowerShell command.

It checks the environment (WSL2, memory, disk, .wslconfig, Nix); KVM (/dev/kvm,
its group, and a tiny guest booted under KVM and under TCG, with the same loop
timed in both and on the machine itself, which must differ by several times);
the display (a QEMU GTK window through WSLg for a few seconds, and whether you
saw it); the GPU (/dev/dxg, the Windows driver libraries, the OpenGL renderer in
four environments, and virtio-vga-gl with gl=on); the Windows side (interop,
mstsc.exe, whether Windows reaches a port that WSL listens on); whether memory,
cores and disk are enough for the real guest; and virtiofs, which the real guest
uses for its /nix/store and every shared directory (/dev/fuse, virtiofsd as the
user with the options of nixpkgs' runner, and a guest that mounts the host's
/nix/store and a writable directory, reads, writes and compares). Each probe
runs apart from the others, so one that fails leaves the rest alone, and the
output ends in a block to paste back, which is also written to
wsl2-probe-report.txt.

The flake wraps the script with QEMU, virtiofsd, Mesa's eglinfo and glxinfo and
socat from nixpkgs, and builds the guest, a kernel from the binary cache, the
two modules of it that virtiofs needs and a static busybox initrd, as
wsl2-probe-guest, so nothing is compiled on the machine. Two checks keep the
script from rotting: wsl2-probe builds the wrapper, which runs ShellCheck over
the script and over the guest's init, and wsl2-probe-dry-run runs --dry-run in
the build sandbox, which has no KVM, no network and no display.
docs/dev/wsl2-probe.md says what each check means and how to read it.
2026-10-28 13:27:11 -07:00
LGUG2Z e8497344e7 chore(deps): flake.lock: update
nixpkgs moves from d3b13ad (2026-10-24) to f05535a (2026-10-27) and rust-overlay
from c7e9d51 to 19ecdd0; the other six inputs have no newer revision. KWin,
Plasma and KRDP go from 6.8.1 to 6.8.2, so testedMax in nix/supported-kwin.nix
goes to 6.8.2, with its dated comment. Qt (6.12.0), FreeRDP (3.32.2) and the
glibc of nixpkgs (2.44) keep their versions. The stable Rust toolchain stays at
1.99.1; the nightly rustfmt moves from 2026-10-27 to 2026-10-28.

nixpkgs now has KDE Rounded Corners 0.11.0. nix/kde-rounded-corners.nix still
sets the version and the source, because its patch is written for the 0.11.0
source, so the package does not change: it builds with its patch, and Geometry
Change builds, against KWin 6.8.2. The dated sentences of README.md, DESIGN.md,
PLATFORM.md, COMPARE.md and of the docs of kwin/effects.rs say 2026-10-28 and
the new versions. Measurements taken on 6.8.1 keep their date and version, since
none was repeated on 6.8.2.

The glibc floor does not move: komorebi and komorebic still need 2.39 on aarch64
and on x86_64 (pidfd_getpid and pidfd_spawnp, both weak references), measured as
the build job measures it, before and after patchelf.

The gate with the nix checks passes on an aarch64 box and on x86-1,
nixos-module-eval among them (the KWin version scenarios included). The VM
suites run against the tip of these two commits; the result is in the message of
the next commit.
2026-10-28 11:27:44 -07:00
LGUG2Z 70c2affa9d docs(repo): compare the three komorebi programs and fix what the docs got wrong
docs/dev/COMPARE.md says what komorebi for KDE shares with komorebi for Windows
and komorebi for Mac, what differs, what KDE does by itself and what is not
supported, for a reader who knows komorebi on one of them. Its tables are
generated by scripts/compare-gen.py from this checkout and from checkouts of the
other two, because a count or a name typed by hand is wrong after the next
change to a command, a key or an event. The script also checks the notes that
say why a command has no counterpart, and the sentences of the document that
depend on a table, and stops when one of them is no longer true. CI has neither
checkout and cannot run it; CONTRIBUTING says when to run it. The tables have no
column for the commit that added a row: the plan that numbered the commits is
not in the repository, and a number the script cannot derive for the next commit
would stop it.

The other documents had places that said something the repository does not carry
or does not do:

- DESIGN cited docs/kde.md, which no commit carries, said spike 8 "asked
  whether" one komorebi.json could be shared, and described the default of the
  key binding mode as a plan. Each entry now states the rule and the evidence.
  The first entry no longer says that the Windows implementation is the
  reference: both it and the macOS one were read, and the native thing is
  preferred where the other two need a workaround.
- "The spikes" was used in PLATFORM, DESIGN and COMPARE and defined nowhere.
  PLATFORM's vocabulary defines it, and PLATFORM has a list of what was not
  measured, which COMPARE links to.
- ARCHITECTURE said a maximized window is hidden with its workspace and shown
  with it, and that the code has a TODO for cycling the focus. A workspace
  switch hides nothing, since KWin shows and hides the desktop's windows, and
  the cycle leaves a maximized window as it is; the TODO and the commented-out
  code under it in window_manager.rs are replaced by a comment that says so. The
  Key bindings section no longer repeats the headings of the sections before it.
- README listed the subcommands that work without the daemon, and the list was
  short. It gives the rule and one example of each kind.
- The page on changing monitors opened with "nothing to configure" and then
  named display_index_preferences.
- COMPARE said KWin waits 300 ms for the pointer to settle before it moves the
  focus. That is KWin's default (DelayFocusInterval, in KWin 6.8.1), and
  komorebi sets it to 0 while focus follows mouse is on; the page now says so.
- ARCHITECTURE's opening list of what the page describes stopped at the borders
  and had no word of animation, themes, wallpapers, transparency, focus follows
  mouse or key bindings. Two link definitions that it had twice are there once.
- PLATFORM's list of what was not measured has the progress of the stock
  animation effect against the clock: one capture showed none four seconds into
  a 30 s animation, where the curve gives about 60 %.
- README, docs/index.md and ARCHITECTURE link COMPARE.md.
- COMPARE's list of the methods of Backend has drag_end, which the end of a
  resize is read through, and compare-gen.py has the row and the counts.
2026-10-28 07:39:49 -07:00
LGUG2Z 8b9cde08d0 build(nix): Plasma guests to develop against
The VM tests boot a machine, run a scenario and power it off. Trying a change by
hand needs a Plasma session that stays up, and on a Mac, one that renders on its
GPU.

nix/dev/plasma-vm.nix is a guest for QEMU on an Apple silicon Mac, headless or
with a Plasma session that logs in by itself, and nix/dev/plasma-utm.nix is that
session as a disk image for UTM, whose QEMU renders on the Mac's GPU. They are
nixosConfigurations of the flake, and install komorebi with services.komorebi
from the flake's packages. They carry no key, password or configuration of
anyone's: the console logs in by itself, ssh takes the keys a user puts in the
guest, and the RDP password is a file in the guest. plasma-guests-eval evaluates
the three on every system.

nix/dev/krdp.nix runs KRDP in the session, and the slow suite vm-rdp runs the
same unit in the session node: it waits for a password and listens once it has
one, FreeRDP's client is let in with the password and not with another, and the
picture the client gets on an X server of its own is the display. FreeRDP
decodes KRDP's H.264 with OpenH264 and shows nothing, so the guests' client is
built without it and decodes with FFmpeg. Keys over RDP stay unverified, since
no client can script them against KRDP; PLATFORM.md says so and what the suite
checks.

docs/dev/utm.md says how to build and run the guests, and what a builder has to
be.
2026-10-28 07:39:49 -07:00
LGUG2Z 04aa311f2e build(nix): the KWin version the module supports
komorebi relies on the per-output virtual desktops of KWin 6.8, and the
repository follows the tip of nixpkgs-unstable, so a system can have a KWin that
is too old, or one that no VM suite has run against yet.

nix/supported-kwin.nix records both ends: min, the oldest KWin komorebi
supports, and testedMax, the newest one every VM suite has passed against
(6.8.1, with nixpkgs d3b13ad). It is a plain attribute set, so a CI job can read
it with `nix eval --file`. The module asserts that pkgs.kdePackages.kwin is min
or newer, and warns when it is newer than testedMax;
services.komorebi.kwin.untestedIsError makes that an error.

nixos-module-eval gives the system a KWin of a version of its own, so that no
check depends on the KWin of the lock, which moves with the tip, and checks the
assertion, the warning and the option in ten more checks. CONTRIBUTING.md,
PLATFORM.md and vm-tests.md say how testedMax is raised: by a commit, once every
suite has passed against the newer KWin, that says which suites ran.
2026-10-28 07:39:49 -07:00
LGUG2Z 9cb26daf38 build(nix): keybindings in the module
On NixOS the key bindings belong in the configuration, with the rest of
komorebi. services.komorebi.keybindings takes the format (skhd, whkd or sxhkd),
the bindings as text or as a file (exactly one of the two, asserted), the mode
(auto, engine or static; engine by default, as PlanMode::default() is),
chordTimeout (3000 ms), layout (us, the only one), strict and appAliases. skhd's
.load is refused by an assertion: the file it reads would not be in the store.

At build time the module runs komorebic hotkeys compile on the file in the
package komorebi-keybindings, whose share/kglobalaccel goes into the system
profile, where KWin finds the static shortcuts when the session starts. Its
diagnostics are in the build log and in diagnostics.txt, and strict passes
--strict, which fails the build on a warning or an error. execStart gains
--keybindings with the same store file, its format and mode, --chord-timeout and
an --app-alias per alias, so the daemon plans the file as the build did and runs
the bindings the build left to the engine. The unit already releases the keys
after the daemon stops.

The check keybindings-fixtures runs every file of the fixture corpus through
komorebic hotkeys compile --mode auto --dry-run, the command the module runs,
and diffs what it prints against nix/tests/keybindings.expected; it also checks
that docs/{skhdrc,whkdrc,sxhkdrc}.sample are the corpus's sample case.
nixos-module-eval gains the key binding scenarios: text, file, both, no format,
.load and a layout that is not us, and one with the typed config together with
key bindings, whose execStart has the checked file and then the keys. The corpus
gains the case vm, in the three formats: a binding a shortcut file can hold, and
one on the comma key, which X-KDE-Shortcuts cannot, so that the engine runs it
in auto mode. The VM suites vm-keys-skhd, vm-keys-whkd and vm-keys-sxhkd
configure the module with that case in their format and press both keys; they
share one script through the generator's script attribute.

For strict, nixos-module-eval has two scenarios with the same skhd text, which
has a line komorebic cannot read: strict = true and the default. Evaluation
never reads the key bindings, so the checks read the script of the build: with
strict it runs hotkeys compile --strict and exits 1 when that fails, without it
there is no --strict and the diagnostics are only printed to the build log. A
third check says the daemon gets the same command line either way. ARCHITECTURE
names them in the row for strict.

komorebic quickstart writes the sample skhdrc next to komorebi.json and
applications.json, from docs/skhdrc.sample, where the daemon and hotkeys compile
read it; the flake's source filter keeps that file for the build.

Docs: the user guide docs/usage/keybindings.md, the three sample files,
docs/example-configurations.md for KDE and NixOS, the options in
docs/installation.md, and the module's section of ARCHITECTURE. Whether the
engine or static shortcuts should be the default stays open in DESIGN: a static
shortcut took 233 ms to run its command at the median on KWin 6.8.1, the engine
3 ms, but only the static shortcut survives a dead daemon.
2026-10-28 07:39:49 -07:00
LGUG2Z 2f61b62276 feat(kwin): survive restarts of the daemon and KWin
kglobalaccel keeps the actions of a client that died, and their keys: a daemon
that is killed leaves its keys swallowed and running nothing until they are
unregistered. And kglobalaccel runs inside KWin, so a KWin that restarts forgets
every key komorebi registered. Neither should leave a user without keys.

kwin::hotkeys::release unregisters every action of the component, whatever
registered it, and komorebic hotkeys release runs it, with or without a daemon.
The NixOS module's unit runs it after the daemon stops (ExecStopPost), however
it stops, and a start already takes over what is left. The KWin backend's
NameOwnerChanged watch now also follows org.kde.kglobalaccel: when it has an
owner again, kwin::hotkeys::rebind loads the key bindings into the new one,
trying for 15 seconds.

Loading moves from the daemon's main into kwin::hotkeys::load, which keeps its
settings (Keybindings: file, format, plan mode, engine options), so that reload
can read the file again: the message ReloadHotkeys, komorebic hotkeys reload. A
new engine replaces the running one without unregistering its keys, and takes
them over as a start does, so the keys both files have stay grabbed. The message
HotkeysState answers with the engine's mode, the combos it holds and whether a
chain is pending (null without an engine); komorebic hotkeys state prints it
with the actions kglobalaccel has for the component. komorebic now depends on
komorebi for the two calls to kglobalaccel, and on serde_json for the state.

Tests: the socket messages without key bindings, state and release without a
session bus, reload without a daemon, and a nixos-module-eval check of
ExecStopPost. The VM test vm-restart-survival runs the module's unit in a Plasma
session: a key works, hotkeys state shows it; with the daemon stopped by SIGSTOP
the key runs nothing until hotkeys release gives it back; after SIGKILL the
unit's journal shows the release and the restarted daemon has the key; after the
KWin process is killed, the daemon registers the key with the new KWin; and a
reload of a changed file swaps the key.

vm-keys-engine asks the engine and does not sleep. A key that is not grabbed yet
reaches the focused window, which showed as a binding that does not run, so the
suite presses one once komorebic hotkeys state answers, which it does when the
engine has made the calls of its last event: for a mode, for a chord chain
beginning and ending, and for the keys of the application that takes the focus.
It waits for the old daemon to exit before it starts the next (a daemon started
meanwhile failed to load its KWin script), and activates a window until KWin has
it (KWin lists a window before it can have the focus). The last check reloads
the key bindings with komorebic hotkeys reload while wev has the focus and every
binding is for the terminal, a start with no key to grab.
2026-10-28 07:39:49 -07:00
LGUG2Z b49c4fab0a feat(kwin): the daemon registers with KGlobalAccel
A static shortcut can only be one combo, pressed, the same in every application.
Modes, chord chains, release bindings and per-application bindings need komorebi
to hold the keys and decide what each press does: the engine of
komorebi-hotkeys, a state machine with no I/O. This commit runs it against
KWin's global shortcut daemon, which KWin 6.8 runs inside itself on the session
bus.

kwin::hotkeys owns the component komorebi in kglobalaccel, with one action per
combo, named after the combo (Alt+H). An activation of the engine is
setShortcutKeys(id, [[combo]], 6), with a doRegister the first time; a
deactivation is setInactive, after which the key reaches the focused window
again. The calls of one event are sent without waiting for each other's answers,
which Phase 0 measured at 60 calls in 2.4 ms. globalShortcutPressed and
globalShortcutReleased of /component/komorebi feed the engine;
globalShortcutRepeated is ignored, so a held key runs its binding once. A
komorebic command runs the komorebic next to the daemon with its words when they
need no shell, anything else runs through sh -c. The calls are computed by
Registry, which is pure and tested; the D-Bus part is proved by the VM test.

The engine hears the presses at the path /component/komorebi and does not ask
kglobalaccel for the object with getComponent. kglobalaccel makes the object
when the component gets its first action, so a start with no key to grab (every
binding is for an application other than the one with the focus) has none to ask
for: getComponent fails with NoSuchComponent, and an engine that asked would
never start. A signal is matched by the path it comes from, so the presses are
heard as soon as there is an object. PLATFORM.md says so.

kglobalaccel keeps the actions and the grabs of a client that was killed, so a
start is an idempotent reconcile: the actions of the component that the keymap
names are taken over and released unless the default mode wants them, the others
are unregistered. A stop releases and unregisters everything, from
KWinBackend::shutdown, which SIGINT, SIGTERM and the Stop message all go
through.

The daemon takes --keybindings FILE, --keybindings-format skhd|whkd|sxhkd (by
default from the file's name) and --keybindings-mode (the mode komorebic hotkeys
compile wrote the static shortcuts in, so that the daemon leaves those to KDE);
without --keybindings it reads the first of skhdrc, whkdrc and sxhkdrc in the
config directory (HOME_DIR, KOMOREBI_CONFIG_HOME). It logs one line about the
file and the diagnostics at debug level. An engine that cannot start is logged
and the window manager runs without keys; the fake backend runs none.

The VM test vm-keys-engine starts the daemon in a Plasma session with an skhdrc
in the config directory, presses its keys (a command, one through a shell,
komorebic retile reaching the daemon), kills the daemon and sees its keys stay
registered, starts it with another file and sees the old keys unregistered and
the new ones work, and stops it with komorebic stop, after which no key is
registered and none runs. The library gains spawn_komorebi,
kill_spawned_komorebi and shortcut_actions, and Checks.finish names the checks
that failed, since nix build shows only the last lines of a failed run. The
suite's node turns the module's unit off: the test runs daemons of its own.
2026-10-28 07:21:14 -07:00
LGUG2Z e1a227434b feat(cli): turn key binding files into KDE static shortcuts
komorebi users keep their bindings to komorebic in a file of skhd, whkd or
sxhkd. On Plasma under Wayland no client sees the keys of another, and the
program that runs a command on a key is KDE's global shortcut daemon, which runs
inside KWin. A binding that is one combo, pressed, the same in every
application, in a file without modes, can be a static shortcut: a .desktop file
that KWin reads by itself, that shows in System Settings and that works with no
komorebi process at all.

komorebic hotkeys compile reads a file of any of the three formats, plans it
with komorebi-hotkeys in the mode it is given (auto, engine or static; the
default is the crate's), prints the diagnostics of reading and planning, and
writes the static shortcuts to $XDG_DATA_HOME/kglobalaccel or --output,
replacing the files it wrote before and no other. --strict refuses a file with a
warning or an error before writing anything, --dry-run says what a write would
change, and --reload hands each new file to the running KWin. komorebic
export-kde-shortcuts keeps the spike's interface for an skhdrc, in the static
mode. Both are thin: the reading, the plan and the files are the crate's. The
bindings that need the engine are counted; the daemon does not run them yet.

docgen gives a subcommand of a subcommand a page of its own
(hotkeys-compile.md). KOMOREBI_CONFIG_HOME, or ~/.config/komorebi, is where
komorebic looks for a file it is not given.

komorebic check gains a section for the key bindings, between the configuration
and the desktop: the first key binding file of the config home, how many
bindings it has and where they go, the lines that cannot be read (a warning, and
the first five) and the bindings not realized as their own daemon would (a
note), and whether the generated files in the shortcut directory are the ones
hotkeys compile would write now (a warning with how many to add, to change and
to remove). --configuration-only keeps it, as it looks at files.

The VM test vm-keys-static boots a Plasma session with shortcut files that
hotkeys compile wrote into the system profile at build time, presses their keys
through QEMU's keyboard, and sees each command run, a % in one of them included;
then it writes a binding in the middle of the session with export-kde-shortcuts
--reload and presses it. The library gains Keys, which sends a combo as KDE
writes it through QEMU's sendkey.
2026-10-28 07:20:57 -07:00
LGUG2Z 44f7236698 feat(cli): check the desktop
komorebi fails quietly when the desktop is not what it was made for: over ssh
there is no session bus, a daemon whose script is not in KWin tiles nothing
while it runs, and komorebi needs KWin 6. `komorebic check` now looks at the
desktop after the configuration, and `--configuration-only` leaves that out for
a machine that is not the one komorebi will run on.

It looks at the session (KDE, Wayland), the session bus, KWin on it and KWin's
version, the daemon and its script (the daemon is running when its socket takes
a connection, the script is in KWin when KWin says it has loaded it), and what
starts komorebi at login. A bus that cannot be connected to or does not answer,
and no KWin on it, are errors, and the looking stops there because nothing after
them can be answered. Every call has two seconds to answer, so a bus that never
does cannot hold the check.

The test of the missing session bus used to remove the address and the runtime
directory from the environment and so depended on it: a machine with a user bus
at /run/user/UID/bus answered it. It now gives an address with no socket behind
it. A bus that accepts and never answers, the autostart finding and
--configuration-only have tests too, and the VM suite vm-check runs the command
against a real KWin with the daemon running, with its script gone, with no
daemon and with no KWin on the bus.

It also looks at the two effects KWin draws and animates with, and at the Plasma
shell. Only komorebi's builds of them do everything komorebi asks (a color for
each kind of window, every animation style), so the check tells a build by what
it contains: the interface of the border effect in its plugin file or its object
on the bus, a Curve setting in the animation effect's script. It looks for them
where KWin looks, from KWin's own environment, and a stock effect is a warning
only when the configuration asks for what it cannot do (borders, an animation
style other than the stock curve), and so is a copy in the user's own data
directory that hides komorebi's. The Plasma shell is looked for when the file
has wallpapers. vm-check runs all of it against KWin.
2026-10-28 07:20:57 -07:00
LGUG2Z e563988258 feat(wm): fullscreen is left alone
A fullscreen window covers its monitor on purpose. Moving it would take it out
of fullscreen, or be ignored, so Window::set_position does nothing for a
fullscreen window, and every move the window manager makes goes through it.

The bridge reports KWin's fullScreenChanged as a fullscreen event, and the table
keeps what it says, which is what the window manager asks. The window stays in
its container, and the others are tiled as if it were not fullscreen. When it
comes back from fullscreen, KWin gives it the frame it had before, which may not
be its tile any more, so the workspace is tiled again.

Section E of the features suite checks it in a real Plasma session: a fullscreen
window covers the monitor, a window that opens does not take it out of
fullscreen, and it is in a tile that overlaps no other when it comes back.
2026-10-28 07:20:57 -07:00
LGUG2Z d6ad3b4292 feat(wm): a window the user maximizes
KWin maximizes a window for the user as well as for komorebi: the title bar, a
double click and a shortcut do it, and nobody asks the window manager. A window
that stayed a tile would be asked to move at the next retile, so komorebi has to
know.

The bridge reports KWin's maximizedChanged as a maximized event, true only when
the window is maximized both ways. The table turns it into a MaximizeChange, and
adopt_maximize_change takes the window out of the layout as toggle-maximize
does, or, when the user restores it, puts it back in a container of its own at
the index it came from. A window that is already maximized when komorebi starts,
or that opens maximized, is the workspace's maximized window from the start.
komorebi's own maximize is reported too and finds the workspace as it left it,
so it changes nothing.

While a window is maximized, what is asked of the layout does nothing, by the
same rule as for toggle-maximize.

A maximized window of a monitor that is unplugged goes to the monitor that is
left and stays maximized: evacuate_monitor makes it the maximized window of the
workspace it goes to, and the maximized window that workspace had goes back to
its tile. Until now it came back as a tile.

vm-user-maximize maximizes windows with kdotool in a headless KWin, and checks
the daemon's state, the frames, that the layout commands do nothing, and that
toggle-maximize and the user's restore both put the window back in its tile.
2026-10-28 07:20:57 -07:00
LGUG2Z 0210ec285a feat(kwin): sleep, wake and lock
While the screen is locked or the machine is asleep, KWin sends its clients no
frame callbacks, so nothing komorebi asked for in that time is done: a window
that something else moved stays where it was moved, and the monitors can come
back in another order or at another size. Nothing told komorebi it had happened,
and it kept the layout it had until the next event.

kwin::power now watches for the two ends of it: logind's PrepareForSleep on the
system bus, which only logind may send, and the lock screen's ActiveChanged on
the session bus. When the machine wakes (2.5 seconds after it, while the outputs
are found one after another) or the screen is unlocked (0.6 seconds after it,
once the lock screen has gone), the monitor reconciliator gets a Wake and calls
wake_up: it reconciles the monitors as for an unplug, reads their sizes, follows
the windows to the monitors they are on and tiles every monitor, unless the
window manager is paused. Going to sleep and locking do nothing, and a machine
with no system bus or a session with no lock screen runs without them. The lock
screen is a window of KWin's that the bridge already does not report as an
application window, so it has no tile.

The bridge also compares KWin's active window with the one it last announced
once a second, because the window that is active again after an unlock is not
always announced. vm-lock-sleep locks and unlocks a real Plasma session and
checks that the windows come back to their tiles; a real suspend cannot be
tested in the QEMU guest, only the signal wiring, which tests/power.rs covers on
private buses.

vm-lock-sleep waits for the panel's room before it opens the windows, so that
they are tiled once, and compares the window that was moved with the tile the
daemon has for it when it looks, give or take a cell of the terminal's font, and
not with the frames read earlier: the room can come while the screen is locked,
and the tiles read before are then not the ones. The wait is not a check,
because a session on a machine as slow as TCG may never take the room.
2026-10-28 07:20:57 -07:00
LGUG2Z 40eec2f6be feat(wm): follow a window moved to another output
A window can be moved to another output by something other than komorebi: a KWin
shortcut that sends it to the next screen, the window menu, an application.
komorebi had it in a container of the old monitor, whose tile is on a screen the
window is not on, so it sat untiled where it landed.

The bridge now reports a window's output changing, with the frame the window has
by then, since the signal and the frame arrive in no fixed order, and the table
makes it an OutputChange event. The window manager handles it with
WindowManager::follow_windows_to_their_monitors: every container whose focused
window, and every floating window, is on another monitor than the one it has it
on moves whole to the workspace of the same number on the monitor that holds the
middle of its frame, and the monitors that changed are tiled. Every window is
looked at, not only the one in the event, and a move komorebi made itself is in
its state already and finds nothing to do.

Nothing is followed while the user drags a window: the end of the drag decides
where that window goes, so the method returns at once while a move or a resize
by the user is going on.

KWin also moves windows for a moment after outputs come and go, so the monitor
reconciliator looks again 1.5 seconds after each notification, where it used to
trust what it found at once. vm-hotplug sends windows over with kdotool as well
as with komorebic.
2026-10-28 07:20:57 -07:00
LGUG2Z f530318cee feat(wm): outputs unplugged and plugged back
KWin takes the windows of an output that goes to the outputs that are left and
puts them where it likes, so komorebi's tiles for them were on a screen that was
gone and the windows sat untiled where they landed. The monitor reconciliator
now deals with a change in the set of monitors: the table announces it as a
display connection change instead of a resize, and reconcile_display_change
reads the monitors again, keeps each one that went (workspaces, layouts,
paddings, offsets) in a cache, has the window manager move what was on it to the
monitors that are left, and removes it.

The move is WindowManager::evacuate_monitor, in the new reconcile module: each
container and floating window goes to the workspace of the same number on the
monitor that holds the middle of its window, or on the first monitor that is
left when it is on none, and the workspaces that changed are tiled. A stack goes
as one container, and a monocle and a maximized window come as ordinary
containers. A monitor that is plugged in takes what was cached under its serial
number id, less the windows that closed or that the window manager has elsewhere
by now, so it comes back with its settings and none of the windows it had. A
monitor komorebi never saw gets workspaces of its own, and when the compositor
reports no output at all nothing is done.

The workspace rules of a monitor that goes are dropped, and those of the
workspaces of one that comes back apply again. komorebi.json fills the cache as
well: a monitor that has the entry its display index preference names is kept
with it, and so is the entry of each preference whose monitor is not connected,
which that monitor takes when it is plugged in.

vm-hotplug now sends two windows to the other monitor with komorebic, disables
that output, enables it again and sends a window over once more.
2026-10-28 07:20:57 -07:00
LGUG2Z c8dc92a236 feat(wm): a monitor changes size
When an output gets another resolution or scale, or a panel starts, grows or
hides, the work area the layout fills is not the one komorebi tiled in, and
nothing moved the windows until something else tiled them again.

The bridge now sends the outputs, with their geometry and the work area of the
desktop each shows, when KWin says they changed, when a dock window opens,
closes or moves, and once a second, because KWin has no signal for the room a
panel leaves. The table turns a monitor whose size or work area changed into a
monitor notification. It is not a window event, since it is about no window: it
goes to the monitor reconciliator, a thread that takes the window manager's lock
as the threads of events and messages do, reads the size and work area of every
monitor again and tiles the workspace in front of the focused monitor. Another
monitor's windows keep their tiles until a retile. Every subscriber is notified
of it, whether or not the state changed.

vm-hotplug starts with the scale of one of two outputs changing under a running
daemon. The VM library gets kde.outputs, which lists, disables, enables and
scales outputs, for the commits that unplug and plug them.
2026-10-28 07:20:57 -07:00
LGUG2Z c03e5d2970 feat(wm): the neighbours follow the drag
While the user drags an edge of a tiled window, the tiles next to it follow, and
not only once it is let go.

KWin reports every step of a resize with the frame the pointer asks for. The
bridge passes one on every 16 ms at most, the newest, with a single-shot timer
for the last one of a burst, and the window manager keeps at most one step
waiting, since each says where the edge is now. ResizeStart keeps the layout as
it is when the user takes hold (LiveResize), and every step and the end are
worked out from it: a drag ends the same with its steps as without them, and one
that is taken back puts the neighbors back. The dragged window is the user's
until they let go, so Window::set_position leaves the windows being resized
alone, whatever tiles the workspace.

vm-live-resize holds the edge part way and all the way and reads the neighbors
while the button is down. With the neighbors following, KWin's window snapping
pulled a dragged edge 9 pixels past the pointer in vm-drag-resize, since the gap
between two tiles is inside its default zone, so both drag suites turn snapping
off; PLATFORM.md and drag-resize.md say so. Like vm-drag-resize, it drags
against the daemon the module starts with the session, and reads the drags that
daemon has handled from the unit's journal.

vm-live-resize waits for the panel's room (Kde.wait_for_panel_room) before it
opens the windows. The daemon learned of the room while the pointer was down in
a first case that had begun without it, and the retile that came with it moved
the neighbor, not the pointer.
2026-10-28 07:20:57 -07:00
LGUG2Z af560f47d7 feat(wm): resize by dragging an edge
Dragging an edge or a corner of a tiled window resizes its tile when the user
lets go: each edge of the tile moves by what the pointer moved it, the neighbors
give up or take the room, and the window is put on its tile.

A drag is measured from the frame the window had when the user took hold of it,
edge by edge, and resizes the container of the window that was dragged, through
resize_window and so within its bound (no tile below a tenth of the work area).
KWin reports the window's frame all through a drag, so the table keeps the frame
of the start for the window manager (Backend::drag_origin). At the end the frame
can still be short of the pointer, because KWin changes it once the client has
drawn at the new size; the bridge keeps the frame the last step of the drag
asked for and sends it as `requested`, and the end of a resize is that frame.
The window of a stack has the tile of the stack; a floating window and the
window of a monocle have none, and are left as the user made them.

The end of a resize is read apart from the window's frame, and that deviates
from the spike. The spike stores the frame the last step asked for as the
window's own frame, where the next frame event replaces it. KWin sends one a
millisecond after the end, with what the client has drawn so far (660 pixels of
the 672 asked for), and a window manager that is busy with an earlier event
reads that frame when it handles the end: a tile moved by 60 pixels for a drag
of 72. Here the table keeps the end apart (Table::drag_end, and
Backend::drag_end for the window manager, None by default), ResizeEnd reads it
as ResizeStart reads drag_origin, and the window's frame stays the one KWin
reports. A resize that sent no step ends at the frame. The order that failed is
a test of the KWin backend with the real table and window manager, in which the
tile took 140 of the 200 pixels without this.

komorebi-layouts at the pinned revision has the fix of LGUG2Z/komorebi#1765, so
nothing is vendored, and docs/drag-resize.md says what a drag does and why each
rule is there. vm-drag-resize drags edges and corners of three tiles with the
pointer that the session node has (komorebi_vm.Pointer), whose moves are batched
in the guest. The suite drags against the daemon the module starts with the
session, and reads the drags that daemon has handled from the unit's journal.

vm-drag-resize waits, for 30 seconds at most, for the daemon to have the room
KWin takes for the panel before it opens the windows (Kde.wait_for_panel_room).
The panel moved every tile in the middle of a case that began before the daemon
learned of it, which showed as an edge that was not dragged. It is a wait and
not a check, since a session that is too slow may never take the room.
2026-10-28 07:20:57 -07:00
LGUG2Z f5933d1ff8 feat(wm): drag to swap
A window the user drags with the pointer onto the tile of another window swaps
places with it, and a window let go where there is no tile goes back to its own.
It is the first thing komorebi does about what the user does with the pointer.

KWin reports the start and the end of a drag on the window
(interactiveMoveResizeStarted and Finished). The bridge sends both with the
window's frame, and the end with the position of the pointer: the window manager
asks where the pointer is right after a drag, and when the bridge does not
answer that in time, the table's last position is the one the drag ended at. The
table turns a move into MoveStart and MoveEnd; a resize raises nothing yet. The
bridge also stops asking KWin again for the position komorebi gave the window,
which is the user's now.

Where the window is let go decides the rest. On another monitor it goes there,
with its stack, in the place of the container under the pointer. With the window
container behavior Append it joins the container under the pointer instead of
swapping with it, and with the float override on it floats where it was let go.
A terminal that keeps its frame a few pixels short of its tile is not taken for
a move on its own monitor, as in the spike; the usage page says so.
2026-10-28 07:20:49 -07:00
LGUG2Z 2b116ea2a3 feat(kwin): wallpapers for monitors and workspaces
Plasma keeps one wallpaper for each screen, not for each virtual desktop, so a
workspace that wants a picture of its own has nowhere to keep it in the shell.
komorebi sets the wallpaper of a screen when a workspace comes into view: the
workspace's own, else its monitor's. It does so however the workspace came into
view (a komorebic command, a pager, an application raising a window) and for the
workspace in front when it starts. A monitor and a workspace with no wallpaper
are left alone.

The Plasma shell sets it over D-Bus, with the call plasma-apply-wallpaperimage
makes, but for one screen. Backend::set_wallpaper asks for it every time a
workspace comes into view, and the KWin backend asks the shell what the screen
shows first, so a picture the user changed by hand is put back and one that is
already right is not touched. The request runs in the background, so switching
workspaces never waits for the shell, and goes on trying for about half a minute
when the shell is not up yet.

A wallpaper also gives the borders the colors of the palette made from its
picture, unless it says generate_theme is false, and theme_options choose the
variant and the color each border takes. The colors change before the workspace
is shown, so its borders are right at once.

komorebi.json takes the wallpaper of a monitor and the wallpaper of a workspace,
and a file that is read again shows the wallpaper of the workspace each monitor
has in front at once. vm-wallpaper runs against the real shell: set at start and
by workspace, a switch komorebi did not make, the colors of the borders, a
wallpaper changed by hand, the palette cache, and komorebi that starts before
the shell.
2026-10-28 07:20:49 -07:00
LGUG2Z 9a0693f5d1 feat(kwin): focus follows mouse through KWin's focus policy
KWin has a focus policy that gives the focus to the window under the pointer, so
komorebi asks for it and follows where the focus goes, as it does for any change
of focus. Both implementations komorebi for Windows names are the same thing
here. The KWin backend sets the focus policy and no delay in the user's kwinrc
and has KWin read it, and puts the two keys back as the user had them when it is
turned off and, with every other setting, when komorebi exits. The state of the
daemon says whether it is on.

komorebic gets focus-follows-mouse and toggle-focus-follows-mouse. The session
node gets a pointer that a test drives from inside the guest: the program
komorebi-pointer makes an absolute device with uinput, and komorebi_vm.Pointer
sends it commands. vm-focus-follows-mouse moves it over each of two windows and
checks the focus, then the policy that is put back.

komorebi.json takes focus_follows_mouse, which is on with Windows or Komorebi,
and a file that leaves it out turns it off and gives the user's focus policy
back.

Loading a configuration asks the backend for the focus policy now, so the tests
that load one do it with a backend installed.
2026-10-28 07:20:49 -07:00
LGUG2Z fa1e822e44 feat(wm): transparency for the windows without the focus
The window in front of every container is see-through, unless its container has
the focus on the focused monitor, so that the window being worked in stands out.
The opacity of a window is a property KWin has: komorebi sets it through the
bridge, as a fraction of 255, and KWin draws the window so. The window manager
keeps the opacity it gave each window and, after every event and message, sends
only the difference, makes a window opaque again when it should no longer be
see-through, and makes every one opaque when it stops. Windows an ignore rule
covers, and workspaces that do not tile, are left as they are.

The patched border effect already draws a window again when its opacity changes,
so the border follows it. komorebic gets transparency, transparency-alpha and
toggle-transparency, and the state of the daemon says what the settings are.
vm-features checks the pixels in the middle of a focused window and of one that
is not.

A monocled window is opaque on the focused monitor and see-through on another.
komorebi.json takes transparency, transparency_alpha and
transparency_ignore_rules.
2026-10-28 07:20:49 -07:00
LGUG2Z abfa8c6a5c feat(kwin): easing curves for animations
An animation that moves a window at one pace all the way looks flat, and the
stock Geometry Change has one curve, an exponential ease out. The patched effect
reads a Curve setting, the name of one of Qt's easing curves. komorebi's styles
are those curves with Ease in front, so the look the backend is asked for names
the curve as Qt does, and the effect's settings have it next to the duration. A
cubic Bezier, which only a configuration can say, is not one of Qt's curves and
is an ease out. With the stock effect every style is that ease out.

komorebic gets animation-style, for every animation or the movement of windows
alone. vm-features puts two curves a few seconds into a 30 s move of the same
window and checks that they are in different places.

The animation key of komorebi.json takes a style too, for every kind of
animation or for one.
2026-10-28 07:20:49 -07:00
LGUG2Z 4fa8108110 feat(kwin): animation through the Geometry Change effect
Windows that jump to their new places are hard to follow. komorebi animates
nothing itself: KWin's Geometry Change effect animates a window that a script
moves, and komorebi tells it how long that takes. The look the window manager
asks the backend for now has an animation, a duration that the settings give for
every kind of animation or, when one is set for the movement of windows, for
that one. The KWin backend writes the effect's settings into the user's kwinrc
(a duration, and no cross-fading, since a window going to its place changes
where it is and not what it shows), loads the effect when animation is on, and
unloads it and puts the settings back when it is off or komorebi exits.

komorebic gets animation, animation-duration and animation-fps. The module
installs the effect (effects.animation), and the session node sets the variable
that makes KWin load effects that animate under software rendering. vm-look
reads the screen a moment after a flip of the layout, with a 30 s animation and
without one.

The commands that change a stack turn the animation off while they run, as
komorebi does elsewhere, and komorebi.json takes an animation key (enabled,
duration and fps, each for every kind of animation or for one).
2026-10-28 07:20:49 -07:00
LGUG2Z 22c14ba5be feat(kwin): a border color for each kind of window
The stock KDE Rounded Corners has one color for the active window and one for
the others, and komorebi colors a window by what it is to the layout: the
focused window, the others, a locked container that does not have the focus, a
floating window that has it, a stack, a monocle. komorebi's build of the effect
takes a color for each window over D-Bus. After every event and message the
border manager works out the kind of each window and tells the backend, which
sends the effect what changed, and tells it that no window has a color when
borders are off. Without that build the borders are the look's two colors, and
the backend says so once.

komorebic gets border-colour. The fake desktop keeps the colors it was told, and
gets window_commands, which leaves the look out of the list of what the window
manager asked for. vm-features reads the colors of the kinds of window in a real
session.

The colors of a stack and of a monocled window are read from the fake and, in
vm-features, from the pixels. The border_colours key of komorebi.json is not
here: its colors are written as the Colour of komorebi-themes, which the themes
(49) bring, and 49 adds the key.

The maximize check of vm-features, which was the whole suite, reads a pixel a
quarter of the way into the window and not the middle one: komorebi puts the
pointer in the middle of the window it focuses, and a screenshot has the arrow
on it, white. It read the arrow on most runs once the border effect was loaded.

vm-features waits for the panel's room too, where it starts the daemon for its
sections B to E: the border colors of section C are read from the pixels around
a window, which the panel can cover.
2026-10-28 07:20:49 -07:00
LGUG2Z 2c35e4bb6f feat(kwin): borders drawn by the compositor
komorebi draws no border. KWin's KDE Rounded Corners effect does, outside each
window in the gap the layout leaves, and komorebi says how it looks. The window
manager describes a Look (a border with a width, a radius and two colors) and
asks the backend for it with Backend::apply_look after every event, message and
retile. The backend answers whether borders are being drawn, and the layout
leaves room around the windows only while they are: a border with no effect to
draw it takes none.

The KWin backend writes the effect's settings into the user's kwinrc, with every
inner outline off (the effect's default draws one over the title bar), loads the
effect when borders are on and has it read them again. It unloads the effect and
puts the user's settings back when the borders go or komorebi exits. The module
installs the effect (effects.borders), which is built to stay unloaded until
komorebi asks, and vm-look reads the pixels around two windows in a real
session.

The session library of the VM tests also waits for the panel of the Plasma shell
now: a window komorebi tiles before the panel is made is tiled under it, which
hides the bottom of its border from a screenshot.

Kde.wait_for_panel_room waits until the daemon's work area is not the whole of
its monitor, which is the daemon having learned that the panel takes room, and
vm-look calls it where the daemon starts, before the windows open. The shell
makes its panel and KWin takes room for it a while later, which the daemon
learns of up to a second after, so a daemon that tiles in between puts the
windows under the panel and tiles them again, and a screenshot taken between the
two shows frames as high as the screen. It is a wait and not a check, since a
session that is too slow, as TCG often is, may never take the room.

komorebic gets border, border-width and border-offset, and visible-windows.

komorebi.json takes the same settings as border, border_width and border_offset,
and border_radius, which has no command. The module warns when config asks for
borders and the effect is not installed.
2026-10-28 07:20:49 -07:00
LGUG2Z c6874ff5db feat(cli): quickstart, autostart and the config commands
komorebic quickstart writes an example komorebi.json and applications.json into
the config home, asking before it writes over a file, so a new user has a
configuration to start from. komorebic enable-autostart writes the XDG autostart
entry that has Plasma start komorebi at login, with the komorebi.json it is
given if any, and disable-autostart takes it away; the NixOS module does the
same job for a machine that has it.

komorebic replace-configuration makes the running daemon start over from another
komorebi.json: it pauses its window manager and brings back the windows it hid,
the back of a stack, so that none is out of sight for the new one to miss, then
makes the window manager again from the file, with the monitors and the windows
the backend has, and watches the new file in place of the old one. A file that
does not read is logged and the daemon carries on as it was.
2026-10-28 07:20:37 -07:00
LGUG2Z 5eaa613f50 feat(wm): configuration in komorebi.json
The daemon takes its settings from a komorebi.json: the file --config names, or
the one in the config home ($KOMOREBI_CONFIG_HOME, else ~/.config/komorebi).
Without a file it runs on its defaults, as before.

StaticConfig reads the file in three steps, because the monitors are known only
after the backend has been asked. preload makes the window manager and applies
the settings that are process-wide, postload gives each monitor and workspace
the entry of its place (by the index display_index_preferences gives a monitor's
serial number id, else in order), and main asks KWin for a desktop for each
workspace the file lists. A watcher on the file sends the daemon a
ReloadStaticConfiguration about two seconds after it changes, whether an editor
wrote the file in place or renamed a new one over it, and a file that does not
parse changes nothing.

A workspace can change its layout, its work area offset and its layout options
with the number of its containers, from the file or with komorebic
(workspace-layout-rule, its named form and the two that clear), and
layout_defaults gives every workspace of a layout the options it sets none of.
One file is meant to serve every platform, so a key that does nothing on Plasma
is accepted and ignored. config_parity checks the example and the files of other
platforms; the keys of features that are not in yet are listed there, and the
test fails when a list names a key that is read.

The file also sets what the stacks, the float override, the floating layer and
monocle set while the daemon runs: window_container_behaviour, with its rules by
container count in a workspace, float_override, float_override_placement,
floating_layer_behaviour (for a monitor and a workspace too),
floating_layer_placement and monocle_focus_behaviour. Their features come before
this commit, but a file to put the keys in does not, so they are part of this
one.

komorebic gains configuration, generate-static-config and start --config. The
NixOS module gets configFile, which the unit gives the daemon as --config.
2026-10-28 07:20:37 -07:00
LGUG2Z 05de20749b feat(wm): maximize
A window could be given the work area only as a tile of the layout, which has
padding around it. `komorebic toggle-maximize` has KWin maximize the focused
window instead: it gets the work area of its monitor with the panels left alone,
and it leaves the layout, so that it is in no container. Toggled again, the
window is restored, put in a container of its own at the index it was taken
from, and the workspace is tiled again.

The window is kept in the workspace as its maximized window, with the index it
goes back to, and the state shows both. While it is maximized the workspace asks
KWin to maximize it again on every tiling, and does not move the containers
behind it. The messages that are about the tiles (move, stack, resize, promote,
preselect, lock, float, monocle, and the ones that send a container to another
monitor or workspace) are dropped before they are handled, since there is no
tile to act on. Focusing in a direction finds no container on any side of it, so
the focus goes to the monitor on that side, where a maximized window or a
monocle is given the focus whole. Showing the workspace again restores the
maximized window and focuses it ahead of the containers' windows. A window in
monocle that is maximized leaves the monocle, and one that closes takes the
maximize with it. Backend gets a maximize command, which does nothing unless a
backend implements it; the fake desktop and the KWin backend do, and the bridge
carries it out with KWin's own `setMaximize`. A maximize also makes the bridge
forget the position it was last asked to put the window at, which it would
otherwise set again on the work area's size when the window's frame changed
within a second of a tiling.

komorebi/tests/maximize.rs and maximize_commands.rs drive it against the fake
desktop, with one monitor and with two. The bridge's tests and the backend's
tests read the maximize command from the same fixture, so that what one sends is
what the other carries out. vm-features is a new session suite that reads
screenshots: the first thing it checks is that the maximized window fills the
work area, is the one on top, and goes back to its tile; the vm-commands sweep
runs the new command.
2026-10-28 07:20:37 -07:00
LGUG2Z 0d21dd332c feat(wm): monocle
A workspace could show its windows only side by side. Monocle gives one
container the whole work area: the focused container is taken out of the
workspace's containers, so the layout gives no container a tile, and the next
tiling puts its window on the work area less the padding. Turning it off puts
the container back at the index it came from, and the tiles come back as they
were resized: the container keeps its resize adjustment, and the workspace does
not shorten the list of adjustments while a monocle is on.

The windows of the other containers are hidden on the parking desktop, as the
windows behind the front one of a stack are, and the front window of each is
shown again when the monocle is turned off or the window in monocle closes. A
stack in monocle stays a stack.

`komorebic toggle-monocle` turns it on and off. What focusing in a direction
does meanwhile is set with `monocle-focus-behaviour` or switched with
`toggle-monocle-focus-behaviour`: with `cycle` the monocle goes to the previous
or the next container, with `no-op`, the default, no container is on any side
and the focus falls through to the monitor on that side, where the monocle
window of its workspace, if it has one, is given the focus, and the command
fails where there is no monitor. A container that is moved to a monitor or sent
to a workspace in monocle turns that monocle off first. `cycle-focus` always
carries the monocle along. Giving the focus to another window gives it back to
the monocle window, so that it stays in front. `move` and `cycle-move` are
refused and `toggle-float` is ignored while a window is in monocle, since the
focused container is not the window in front. The monocle container is in the
state, with the index it goes back to, and a window in it that closes takes the
monocle with it.

komorebi/tests/monocle.rs drives it against the fake desktop, with one monitor
and with two. vm-core-e2e checks in a real KWin that the focused window fills
the work area, that the state has the monocle container, that toggling back
restores the tile, that the cycle behavior gives another window the work area,
and that the windows behind a monocle are parked, which a clean stop and the
next start after a crash put right; vm-stacks checks a monocle over a stack; the
vm-commands sweep runs the new commands.
2026-10-28 07:20:37 -07:00
LGUG2Z 331db681e2 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.
2026-10-28 07:20:37 -07:00
LGUG2Z 62e37e44cf feat(kwin): workspaces are KWin's own desktops
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.
2026-10-28 07:20:37 -07:00
LGUG2Z 37b06993d2 feat(wm): tune layouts, and the scrolling layout
`komorebic layout-ratios --columns ... --rows ...` sets the share of the work
area the first columns and rows of the focused workspace's layout take, and
`scrolling-layout-columns N` how many columns of the scrolling layout are in
view. Both are layout options of the workspace, which komorebi-layouts reads.

The scrolling layout is a strip of columns that runs past the edges of the work
area, so its tiles are not clamped to it: the windows of the columns out of view
are off the screen. When a window of a scrolling workspace gets the focus, the
workspace is tiled after the focused container changed, which scrolls the strip
to it.

KWin moves a window anywhere, but keeps 100 pixels of a window it resizes on an
output once the client has drawn at the new size, which pulls the columns out of
view back onto the screen. The bridge remembers the position it was asked for,
for a second, and asks again for the position alone when KWin moved the window
elsewhere, three times at most.

komorebi-layouts compiles `Rect::percentage_within_horizontal_bounds` only with
its darwin feature, which this repository does not enable, so the workspace has
its own copy of it.

A new VM suite, vm-scrolling, after the spike's m2-scrolling script, checks in a
real KWin that the columns out of view stay where komorebi put them and that the
strip follows `komorebic focus`; vm-commands sweeps the two new commands.
2026-10-28 07:20:37 -07:00
LGUG2Z a86e2bdc63 build(nix): a NixOS module for komorebi
On NixOS, running komorebi should take one flake input and one module line. The
flake exports `services.komorebi` as nixosModules.default (and .komorebi). It
adds the flake's overlay, which gains komorebi-full, installs komorebi and
komorebic, and runs the daemon in every Plasma session as a systemd user unit,
wanted by plasma-workspace.target and ordered after plasma-core.target, which
Plasma orders after KWin. systemd starts a daemon that fails again; the exit
status of a SIGTERM (130) counts as success, so a stopped unit is not failed.
The unit's PATH is the one a terminal in the session has, instead of the few
store paths NixOS gives a unit.

The options are enable, package, logLevel and a read-only execStart, the command
line the unit runs. There is no configFile: the daemon reads no configuration
file yet, and an option that linked one would do nothing.

nixos-module-eval evaluates seven scenarios as real NixOS systems and checks
their assertions, the unit and execStart, with builtins.tryEval for the two that
must not evaluate. Nothing in it builds a Linux package, so it runs on a Mac as
well.

The VM nodes import the module, so every suite runs komorebi as the module
installs it. The headless node's komorebi@NAME runs execStart, which has
--log-level, so the nodes set logLevel to debug and the suites stop passing it;
latency, which times the daemon, keeps the default level. The session node
starts the module's unit at login; render-smoke turns it off, since a tiled
terminal would cover the desktop it looks at. A new fast suite, vm-module, logs
in to Plasma and checks that the unit runs the daemon with the module's log
level and PATH, that a window opened in the session is tiled, that a killed
daemon is started again, and that a stop is not a failure.

docs/installation.md covers NixOS first, and building from source on other
distributions. There is no binary cache: the Komorebi license does not allow
third parties to redistribute binaries.
2026-10-28 07:20:37 -07:00
LGUG2Z e2d757bbb0 feat(wm): subscribe to notifications
A program such as a status bar has to follow the window manager, and so far it
could only ask: komorebi on Windows and on macOS sends notifications for that.
The daemon now keeps subscribers, each a Unix socket in its data directory that
a program listens on, and after each event and each message it handles, it
connects to every subscriber and writes the event or the message with the state
after it.

A subscriber can ask to be sent only what changed the state; a window that is
shown and a new subscription are always sent, so a subscriber has the state as
soon as it subscribes. A subscriber that cannot be connected to is dropped with
its socket file and not tried again, and a stopping daemon closes an empty
connection to each one.

komorebic gains subscribe-socket and unsubscribe-socket, and komorebi-client
subscribe and subscribe_with_options, which bind the socket and subscribe. The
tests subscribe through real sockets against the fake desktop: a shown window is
sent with the state that has it, a subscriber that filters is not sent a
question that changed nothing, and one that is gone is dropped.
2026-10-28 07:20:37 -07:00
LGUG2Z 6f657e53e3 feat(cli): komorebic
The daemon answers on its socket, but a person has no way to write a message on
it. komorebic is that way: each subcommand that asks something of the daemon is
one message, sent with komorebi-client, and a question's answer is printed.
komorebic is a separate program because the daemon takes no input but its
socket, so a key binding, a script and a terminal all drive it the same way.

It has what the daemon can answer so far: state, monitor-information, query,
retile and stop. Around them, start runs the komorebi on the PATH in the
background and passes it what follows a --, such as --fake-backend to try it
without a compositor; log follows the daemon's log, which the daemon now writes
to a file a day in LOG_DIR as well as to stdout; data-directory shows where the
socket is; completions prints a shell completion script. The hidden docgen
writes each subcommand's help to docs/cli, in fences that name their language
and wrapped at 100 columns whatever the width of the terminal, so that the pages
are the same wherever they are generated; just docgen runs it.

The user guide starts here: docs/index.md for KDE Plasma, and a page on running
komorebi. tests/cli.rs runs each subcommand against a socket the test serves in
place of the daemon. In the VM tests the guests get komorebic next to the
daemon, and vm-core-e2e reads the daemon's state with it (the windows it
manages, the one it focuses, after KWin restarts too) and stops it with
komorebic stop, which unloads the bridge and removes the socket.

In the VM test library, Kde.state asks again, a second (times the scale) after
an empty answer or a failed command, up to 10 times. The daemon drops a query
when it cannot take hold of the window manager within a second, and komorebic
then prints an empty line and succeeds; komorebic itself gives the daemon a
second to answer and fails when that is not enough. A daemon busy with the first
windows does either on a machine that is loaded or slow, and a suite that polled
the state died on JSONDecodeError. An answer that is not JSON still fails at
once.
2026-10-28 07:20:37 -07:00
LGUG2Z 88aa78b874 feat(wm): a command socket and the first messages
Until now the daemon tiled what KWin reported and nothing else could reach it.
komorebi takes its orders on a socket, as it does on Windows and on macOS: it
handles no key itself, and whatever binds keys runs a command that writes a
message.

The daemon binds komorebi.sock in $XDG_RUNTIME_DIR/komorebi (DATA_DIR), reads
one JSON SocketMessage per line on a thread per connection, and handles each
message with the window manager locked, as the thread that handles events does.
The first messages are the ones the first komorebic needs: State (the window
manager as JSON, from state.rs), MonitorInformation, Query with a StateQuery,
Retile and Stop. A question is answered on the same connection, and the answer
ends when the daemon closes it. Stop unloads the bridge from KWin, removes the
socket and exits; so does a stop signal.

The socket is bound once the daemon is connected to KWin, so a komorebi that
cannot connect, such as a second one in the same session, which the bus refuses,
leaves the socket of the one that runs alone. With --fake-backend the daemon
answers on its socket with no compositor at all, which is how the tests run it.

komorebi-client is the other end: send_message, send_batch and send_query, and
the types the messages are made of. Its doctests serve the socket themselves,
tests/daemon.rs runs it against a window manager over the fake desktop, and
komorebi/tests/socket.rs runs the daemon binary from its start to a Stop.
2026-10-27 15:17:30 -07:00
LGUG2Z 4386003e30 feat(kwin): survive KWin restarting
KWin can go away under a running daemon while the session bus stays: it crashed,
or kwin_wayland --replace took its place. Its windows go with it, and so does
the bridge, which would have reported them closing; in the spikes the old
windows vanished without a single removed event. What the daemon does see is the
bus, so the thread that serves the connection watches NameOwnerChanged for
org.kde.KWin. When the name loses its owner, Table::compositor_lost marks every
window closed and returns a Destroy for each, so the window manager takes them
out of its layout, and their ids are not given out again. When the name has an
owner again, the daemon loads the bridge into the new KWin, trying for up to 15
seconds while KWin is not ready for it, and the bridge reports the new desktop
in a snapshot.

zbus does not end a held call when its caller leaves the bus, so the Poll of the
old bridge is still waiting when the new bridge calls, and it was woken first:
it took the commands for the new KWin and answered them to nobody.
vm-kwin-restart lost the tile of a window opened in the third KWin that way in
one run of three, and a recording of the bus showed the batch going to the old
bridge. A Poll held since before KWin last left the bus is answered with nothing
and passes its wake-up on to the next call, which a unit test holds.

The VM suite vm-kwin-restart (from the spike's spike7-resync) restarts the
headless KWin twice under a running daemon, through Kde.restart_kwin in the VM
library, and checks that the daemon sees KWin go, takes the old windows out of
its layout, loads the bridge into the new KWin, and tiles the windows that open
there.
2026-10-27 14:50:53 -07:00
LGUG2Z b463191d00 feat(kwin): move and focus windows through a held call
With the commands of the Backend trait carried out, the daemon tiles a real
KWin. A command looks up the UUID KWin knows the window by, is queued as JSON
for the bridge, and returns; the held Poll call is answered with the queue, one
millisecond after the first command, so that a retile reaches KWin as one batch.
The bridge sets the frame, activates, raises, minimizes, restores or closes the
window, and reports what it could not do. set_rect records the frame it asked
for in the table, so the window manager sees it at once, and the geometry event
that follows says what the client made of it.

A KWin script cannot move the pointer, so the pointer follows the focus through
KWin's own "Move Mouse to Focus" action, invoked over kglobalaccel once KWin
reports the window active.

The commands are typed in bridge.js, and the declarations of KWin's API let the
bridge set a window's frame, its minimized state and the active window, raise
and close it, and nothing else. The session the tests of both sides share grows
a second batch: the backend's tests check that set_rect, focus, raise, minimize,
unminimize and close send it, and the bridge's tests answer the bridge with it
and check what KWin is asked to do, down to the command for a window that has
closed, which comes back as failed. The bridge's tests also cover findWindow's
fallback to KWin's list, a frame set as a plain object, and a command KWin
throws on.

Three VM suites check it against a real KWin. vm-core-e2e (from the spike's
m2-e2e, the sections that need no komorebic) compares what the daemon asked for,
read from its debug log, with what KWin has, read with kdotool: three windows
inside the screen, not overlapping, sharing it, the same focused window on both
sides, and the same again after a minimize, a restore and a close.
vm-client-geometry (slow) has eight real clients answer the frames komorebi asks
for: Qt programs and xterm take them exactly, foot sizes itself to whole cells,
GTK 4 programs and Firefox keep to their minimum sizes. vm-latency (slow) times
retiles from a recording of the session bus: on HVF, from the daemon's answer to
the last frame KWin reports, p50 17.3 ms, p95 27.7 ms. vm-kwin-events no longer
checks that nothing moves. The VM library gives session commands the test user's
home and data directories rather than root's, which a GTK client was refused.

The bridge's second request for a position after a resize, which matters for
windows off the screen, comes with the scrolling layout.
2026-10-27 14:48:56 -07:00
LGUG2Z 15ec3c1274 feat(kwin): read the desktop through a script in KWin
KWin has no protocol that lets another process read or set the geometry of a
window, but its scripting API has both, and a signal for every change the window
manager follows. So the KWin backend is in two parts: bridge.js, a script the
daemon loads into KWin, which reports what KWin sees and holds no window manager
logic, and a D-Bus service in the daemon, org.komorebi.Bridge, which the script
calls with every event as a JSON string.

Every decision about what an event means is made by the table in kwin::state,
which is pure and is unit-tested with the JSON the script sends: which windows
may be managed, the ids komorebi gives KWin's windows, and the Show, Destroy,
FocusChange, Minimize and Restore events of the window manager. Events reach the
window manager in the order KWin raised them: the Event interface handles its
calls inline (zbus reordered them otherwise), and one channel and one thread,
window_manager_event_listener and listen_for_events, carry them on.

Commands go to the script as the answer to a Poll call that the script always
has open and the daemon holds until it has something to send: in the spikes this
was as fast as any other channel, and it makes one call every 10 seconds when
nothing happens. Here the only command is the cursor query. Every command of the
Backend trait is refused with "the KWin backend only reads the desktop", so the
daemon follows KWin and moves nothing.

The bridge is JavaScript that KWin runs as it is, and it gets types and tests
without a JavaScript build chain. tsc checks it as strict TypeScript through
JSDoc types of every message, against kwin-api.d.ts, a declaration of the part
of KWin's scripting API it uses, in which every property is read-only and
callDBus takes one string. bridge.test.js runs it under node --test against a
fake KWin in a node:vm context. The messages of a session are JSON fixtures that
both sides' tests read: the bridge's tests check that it sends those events, and
the table's and the backend's tests read the same events and commands, so a
message cannot change on one side alone. just check-js and the flake's
komorebi-bridge-js check run both, with nodejs and typescript from nixpkgs;
there is no package.json.

The daemon now connects to KWin by default, and --fake-backend is hidden. The VM
suite vm-kwin-events starts it against a headless KWin and checks, from its
debug log, that the script loads and reports what was open, that KWin's signals
arrive as events and in order, and that stopping the daemon unloads the script.
docs/dev/PLATFORM.md starts with what komorebi needs from Plasma.

zbus 5's zvariant, zbus_names and zvariant_derive are flagged unmaintained,
since their code moved into zbus 6; deny.toml ignores the three advisories.
2026-10-27 14:46:35 -07:00