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.
22 KiB
Architecture
komorebi is a tiling window manager. It decides where every window goes and asks a
compositor to put it there. Everything it needs from the compositor is behind one trait,
Backend, so the window manager is written, and
tested, without one.
This page describes that trait, who implements and calls it, where each of its rules is enforced, the model of the desktop the window manager tiles, the backend that reaches KWin, and how an event about a window becomes a tile. It grows with the code: each part of the daemon that is added has its place here.
Vocabulary
- Window: a top-level surface a client has asked the compositor for, named by the
compositor's
u32id. - Monitor: a connected output, with its bounds and its work area (its bounds minus the panels).
- Workspace: the containers a monitor shows, in order, and the layout that tiles them. A monitor has a ring of workspaces and shows one of them.
- Container: what a layout places. It gets one tile, and every window in it is put on that tile.
- Layout: the arrangement (BSP, columns, rows, ...) that turns a work area and a number of containers into one rectangle, a tile, for each container.
- Event: what the compositor reports about a window: it was shown, destroyed, given
the focus, minimized or restored (
WindowManagerEvent). - Compositor: the program that owns the screen and the windows. On KDE Plasma it is KWin.
- Backend: the implementation of the
Backendtrait that talks to a compositor. - Fake:
FakeBackend, the in-memory backend the tests use. - Bridge: the script komorebi loads into KWin (
bridge.js). It reports what KWin tells it and carries out what the daemon asks. - Table: the KWin backend's model of the desktop (
Table), which the bridge's events keep up to date and the queries are answered from. - Held call: the
Pollcall the bridge always has open, which the daemon answers when it has commands for the bridge, or after 10 seconds with none.
Design
The window manager reads the desktop and changes it through Backend and through
nothing else. A real backend answers from a table that compositor events keep up to date,
and sends commands to the compositor. The fake answers from a table the test fills in, and
records the commands. The window manager cannot tell them apart, which is the point:
the same logic runs against both. The reasons are in
DESIGN.md.
The model
The desktop, as the window manager sees it, is two lists and a pointer:
- Windows, each a
WindowInfo: its id, process, title,app_id, executable, frame, whether it is minimized, whether it is a normal application window (as opposed to a dialog, a splash screen or a dock), and whether it is alive. A window that has been destroyed stays in the table for a short time withalivefalse, so that the event that mentions it can still be logged. - Monitors, each a
MonitorInfo: its id, name, an identity that survives a hot-plug, its bounds and its work area. - The pointer, a position if the compositor says where it is, and the focus, the id of the window that has it.
A command is one of: move and resize a window, focus it, raise it, minimize it or bring it back, close it, and put the pointer in the middle of a rectangle.
The window manager tiles a tree of four levels, each level a Ring: a list with one
element focused, so that "the focused workspace" or "the focused container" means
the same thing at every level.
- A monitor (
Monitor) is one of the backend's monitors: its bounds, its work area, its padding, an optional work area offset, and its workspaces. It shows the focused one. - A workspace (
Workspace) has a layout, its padding, and its containers, in order. - A container (
Container) has its windows. A new window gets a container of its own. A container can be locked, which keeps it at its index while others are inserted, removed or swapped around it. - A window (
Window) is aCopyhandle that carries only the compositor's id. Everything else about the window, and every change to it, goes through the backend.
Tiling a workspace (Workspace::update) is a function of this tree and a work area. The
monitor gives its focused workspace the work area, the padding to fall back to and the
offset (WorkspaceGlobals). The workspace takes the offset and its padding off the work
area, the layout gives one tile per container, in order, each shrunk by the container
padding, and every window of a container is asked to move onto its tile with set_rect. A
tile is clamped to the work area, so it cannot reach onto a neighboring monitor.
The WindowManager keeps the monitors, the windows the user minimized (they are in no
workspace until they are restored), and an index from each window it manages to the
monitor and workspace that have it. It is loaded from the backend at startup: every
monitor, and every open window that is normal, alive and not minimized, on the focused
workspace of the monitor its middle is on. After that it changes only when an event comes
in, and each event ends with the workspace in front tiled again.
The KWin backend
KWinBackend is the backend of a real Plasma session. KWin has no protocol that lets
another process read and set window geometry, so the backend is in two processes:
- The bridge,
bridge.js, a script KWin runs inside itself. It subscribes to KWin's signals and sends each as a JSONEventcall over the session bus, and it carries out the commands it gets as the answer to its heldPollcall. When it starts it sends a snapshot of the desktop. It holds no window manager logic. - The daemon's side,
kwin, ownsorg.komorebi.Bridgeon the session bus, with one object forEventand one forPoll.Eventcalls are handled one at a time, in the order they arrive, and never wait. APollcall is held until there are commands, so that the bridge gets them as soon as they are queued, without a timer and without a call from the daemon into KWin.
Every decision about what an event means is made in the daemon, by the table
(kwin::state): an event goes in, the table is updated, and what follows comes out, a
window event for the window manager or the answer to a query. The table is pure (no D-Bus,
no threads), so the unit tests drive it with the JSON the bridge sends.
The bridge is JavaScript, which no Rust build compiles, and KWin loads the file as it is, so
it is checked without a build step. tsc checks it as strict TypeScript through its JSDoc
comments, which type every message, against kwin-api.d.ts, the part of KWin's scripting
API the bridge uses. bridge.test.js runs it with node --test against a fake of that API.
The two sides share the messages of a session in fixtures: the bridge's tests check that
it sends exactly those events, and answer it with those commands, and the Rust tests feed
the events to the table and check that the backend sends those commands. A change to a
message on one side fails a test on the other.
The path of an event: a KWin signal, the bridge's handler, Event(json) on the session bus,
Table::handle, a WindowManagerEvent on the channel of
window_manager_event_listener, the thread that listen_for_events starts, and
WindowManager::process_event. The queries (windows, window, foreground_window,
monitors) are answered from the table. cursor_pos is the exception: only the bridge can
read the pointer, so it is asked through the held call, and given 250 ms to answer before
the table's last known position is returned.
The KWin backend only reads the desktop: every command is an error, "the KWin backend only reads the desktop", which the window manager logs. When komorebi stops, the backend asks KWin to unload the bridge.
connect makes the backend: it connects to the session bus, loads the bridge into KWin,
and returns once the snapshot has arrived, so that the window manager reads a full table when
it starts. PLATFORM.md has the D-Bus names, the files and the environment
variables; DESIGN.md has the reasons.
The parts
backend: the trait, the two data types, and the process-wide handle (set_backend,backend,with_backend).backend::fake: the fake.ring: theRing, and the macro that gives every type holding one the same accessors (containers(),focused_container_mut(), ...).monitor,workspace,container,window: the four levels of the tree.lockable_sequence: inserting, removing and swapping in a list where some elements are locked in place, which is how a workspace changes its containers.window_manager: theWindowManagerand the operations on it (tile the workspace in front, tile every monitor, take a minimized window out).desktop: loads the monitors and the open windows from the backend.window_manager_eventandprocess_event: the events, and what the window manager does with each.window_manager_event_listener: the channel that carries events from the backend to the window manager, andlisten_for_events, the thread that takes them off it.kwin: the KWin backend, its D-Bus service and the held call;kwin::state, the table; andbridge.js, the bridge.main.rs: the daemon. It connects to KWin, or installs the fake with--fake-backend, loads and tiles the desktop, starts the thread that handles events, and waits to be stopped.
The handle is a process-wide singleton so that a window can be a small Copy value
that carries only its id, and ask the backend for what it needs. A test replaces it for
its own thread with with_backend, which puts the previous one back when the test is
done, also if it panics.
Traits and roles
Backend is the compositor. It is implemented once for each compositor and once for
the fake. The window manager is its only caller, through backend. The contract:
- Queries (
windows,window,foreground_window,cursor_pos) answer from the backend's own table and never wait for the compositor, except the KWin backend'scursor_pos, which waits up to 250 ms for the bridge.monitorsmay fail. - A command returns once the compositor has accepted it, not once the client has acted on
it. The frame a window ends up with is what
window_rectsays afterwards, because a client may refuse a size. - A command about a window that does not exist, or is no longer alive, is an error. It never panics.
shutdown,is_window,window_rectandmonitor_from_pointhave defaults, written in terms of the queries. An implementor should not need to override them, exceptshutdownwhen it created something in the compositor that has to be given up.
What is guaranteed, and by what
| Rule | Enforced by | Proof | Escape hatch |
|---|---|---|---|
| A backend implements every query and command | type system: the methods have no defaults | compile_fail,E0046 on Backend |
none |
| A backend can be shared between threads | type system: Backend: Send + Sync + 'static |
compile_fail,E0277 on Backend |
none |
| The window manager asks for a backend that was never installed | runtime error: backend panics, naming set_backend and with_backend |
# Panics on backend |
none |
| A test's backend does not outlive the test | framework path: with_backend restores the previous one on drop |
with_backend_puts_the_previous_backend_back_when_the_closure_panics |
none |
| A command about a missing window is an error, not a panic | test | a_command_about_a_window_nobody_opened_is_an_error and a_command_about_a_closed_window_is_an_error, against the fake only |
none |
| A locked container keeps its index | framework path: a Workspace inserts, removes and swaps its containers through LockableSequence |
test_locked_containers_with_new_window and test_locked_containers_remove_window |
containers_mut() is the VecDeque itself |
| A window komorebi does not manage is never moved | framework path: handle_event asks Window::should_manage before it acts on any event but a destroy |
dialogs_and_other_non_normal_windows_are_left_alone |
none |
| A destroyed window leaves whichever workspace has it | framework path: the destroy is looked up in the index of managed windows, which is rebuilt at startup and before and after every event | a_window_that_was_open_before_komorebi_started_is_removed_when_it_closes |
none |
| The daemon does not start without KWin, unless it is told to run against the fake | startup check: connect fails, and the daemon exits with the error, code 1 |
without_a_session_bus_komorebi_exits_with_an_error_naming_it |
--fake-backend |
| One komorebi per session | startup check: the session bus gives org.komorebi.Bridge to one connection |
none | none |
| Events reach the window manager in the order KWin raised them | framework path: the Event interface is spawn = false, so the calls are handled inline, and one channel and one thread carry the events to the window manager |
vm-kwin-events ("the Show came first") |
none |
| No event names a window before the event that announces it | convention in the bridge, which announces a window it has not seen when it is activated; the table shows a window once | a_new_window_raises_show_and_then_focus_in_that_order, announcing_the_same_window_twice_shows_it_once, vm-kwin-events |
none |
| A window's id names one window while the daemon runs | framework path: the table numbers each KWin window the first time it sees it, and never gives a number out twice | ids_are_assigned_once_per_window_and_survive_a_new_snapshot, tombstones_are_dropped_after_a_while_and_their_ids_are_not_reused |
none |
| A backend never waits for the window manager | framework path: an event that does not fit in the channel (20 waiting) is dropped and logged | none | none |
| The KWin backend changes nothing on the desktop | runtime error: every command returns "the KWin backend only reads the desktop"; type checker: kwin-api.d.ts declares every property the bridge could set read-only |
vm-kwin-events ("nothing was moved"), tsc |
none |
| The bridge holds no window manager logic | convention, with every decision about an event in the table, which the kwin::state unit tests pin |
the unit tests in komorebi/src/kwin/state.rs |
none |
| What the bridge sends is what the table reads | type checker: the JSDoc types of the messages in bridge.js; tests: the events of fixtures are what the bridge's tests check it sends and what the table's tests feed it |
what the bridge sends for a session is what the daemon's tests read, the_bridges_events_for_a_session_are_the_window_events_komorebi_expects |
none |
| What the backend sends is what the bridge carries out | tests: the commands of fixtures are what the backend's tests check it sends and what the bridge's tests answer it with |
the_commands_of_a_session_go_to_the_bridge_as_the_bridges_tests_answer_it, what the bridge sends for a session is what the daemon's tests read |
none |
| Everything the bridge sends over D-Bus is one string | type checker: kwin-api.d.ts gives callDBus one string argument |
tsc, every call goes to the daemon's names, with one JSON string |
none |
| The bridge uses nothing a KWin script does not have | type checker: the library is ECMAScript 2016 alone, and KWin's API is what kwin-api.d.ts declares |
tsc |
a declaration KWin does not match: the VM tests run the bridge in KWin |
| Queries never block on the compositor | convention only | none | the KWin backend's cursor_pos waits up to 250 ms |
| Only the backend module reaches the compositor | convention only | none | none |
The rows enforced by convention are rules nothing checks. A real backend that waits inside a query breaks one of them, a module that talks to D-Bus directly breaks another, and a bridge that decides something in JavaScript breaks a third: the bridge's tests check what it sends, not that it decides nothing.
One event, end to end
A window is shown, and ends up on a tile:
- KWin raises
windowAdded. The bridge sends anaddedevent with what KWin says about the window, andTable::handlegives the window an id, records it, and returns aWindowManagerEvent::Showcarrying the window and the compositor signal it came from. The event goes on the channel, and the event thread callsWindowManager::process_event, which hands it tohandle_eventand logs how long that took. The end-to-end tests build the event themselves and callprocess_event. handle_eventasksWindow::should_manage, which asks the backend'swindow(id)whether the window is alive and normal. If it is not, the event changes nothing.- The index of managed windows is rebuilt from the containers. If the workspace in front already has the window, or the index has it on another monitor or workspace, the event creates nothing.
Workspace::new_container_for_windowputs the window in a new container after the focused one, inserted throughLockableSequenceso that a locked container keeps its index, and focuses the new container.WindowManager::update_focused_workspaceasks the focusedMonitorto give its focused workspace the work area, the padding and the offset (WorkspaceGlobals), and callsWorkspace::update.Workspace::updateasks the layout for one tile per container, clamps each to the work area, and callsWindow::set_positionfor each window, which is the backend'sset_rect. The fake puts the window there at once; the frame a window ends up with is whatwindow_rectsays afterwards. The KWin backend refuses, and the window stays where KWin put it.- If the new container is the only one, the window is given the focus with the backend's
focus, and the pointer follows it. - The index is rebuilt again, so the next event finds the window.
Where to look in the code
komorebi/src/backend/mod.rs: the trait.komorebi/src/backend/fake.rs: the fake and its tests.komorebi/src/core: the vocabulary the rest is written in.komorebi/src/workspace.rs: tiling, inWorkspace::update.komorebi/src/monitor.rs: the work area, padding and offset a monitor gives the workspace it shows.komorebi/src/process_event.rs: what each event does, and the thread that handles them.komorebi/src/kwin/mod.rs: the KWin backend, its D-Bus service and the held call.komorebi/src/kwin/state.rs: the table, with the shapes of the bridge's events, and its tests.komorebi/src/kwin/bridge.js: the bridge, with the types of its messages;kwin-api.d.tsandtsconfig.json, whattscchecks it against;bridge.test.js, its tests;fixtures, the messages both sides' tests read.nix/tests/vm/suites/kwin-events: the daemon against a real KWin.komorebi/tests/wm_events.rs: the window manager driven end to end against the fake.