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.
This commit is contained in:
LGUG2Z committed 2026-10-28 07:20:57 -07:00
1 parent 40eec2f6be
commit 0210ec285a
17 files changed
+913 -26

No files matched your search

+15 -5
View File
@@ -82,11 +82,11 @@ without a checkout of the toolchain.
some elements are locked in place. It answers the messages on the daemon's
socket (`core::SocketMessage`, `process_command`), shows its state (`state`),
keeps the monitors in step with the compositor's outputs as they change size
and are unplugged and plugged in (`monitor_reconciliator`, `reconcile`),
sends its subscribers a notification of each event, message and change of
the monitors it handles (`notify_subscribers`), and is configured by a
`komorebi.json` that it reads and reads again when it changes
(`static_config`).
and are unplugged and plugged in, and when the machine wakes or the screen is
unlocked (`monitor_reconciliator`, `reconcile`, `kwin::power`), sends its
subscribers a notification of each event, message and change of the monitors
it handles (`notify_subscribers`), and is configured by a `komorebi.json` that
it reads and reads again when it changes (`static_config`).
- [`komorebi-client`](komorebi-client): a library that sends messages to the
daemon, reads its answers, subscribes to its notifications, and re-exports
the types they are made of.
@@ -127,6 +127,8 @@ they need from KWin.
`just check` runs the formatting check, clippy with warnings denied, the tests,
rustdoc with warnings denied, and the type check and tests of the bridge (no
package.json: `tsc` and `node` come from the dev shell), inside `nix develop`.
The test of the signals for sleep and lock starts private D-Bus buses with
`dbus-daemon`, which the dev shell has, and does nothing where there is none.
`nix flake check` runs the same checks as derivations, plus `cargo deny`, the
TOML and Nix formatting, the tests under `cargo nextest`, and
`nixos-module-eval`, which evaluates the NixOS module in real NixOS systems, on
@@ -177,6 +179,14 @@ plugged in again. A window that something else moves to another output (a
shortcut, an application, KWin itself) is followed to the workspace of the same
number on that monitor and tiled there.
While the screen is locked or the machine is asleep KWin sends its clients no
frame callbacks, so what the daemon asked for in that time is not done. The
daemon watches for the machine waking up (logind's `PrepareForSleep`) and for
the lock screen going away (`org.freedesktop.ScreenSaver`), and then looks at
the monitors and at where every window is, and puts each in its tile. The lock
screen is a window of KWin's that the bridge does not report as an application
window, so it has no tile.
The window manager models the desktop as monitors, each with a ring of
workspaces, each with a ring of containers, each with a ring of windows. A
workspace's layout gives each of its containers a tile of the monitor's work
+58 -7
View File
@@ -8,11 +8,12 @@ tested, without one.
This page describes that trait, who implements and calls it, where each of its rules is
enforced, the model of the desktop the window manager tiles, the backend that reaches KWin,
how an event about a window becomes a tile, how workspaces are the compositor's desktops, how
a stack hides its windows, how the window manager follows a monitor that changes, how a message
on the daemon's socket is answered, how subscribers are notified of all three, what the orders a
message gives do, what a drag with the pointer does, how the compositor is asked to draw the
borders, how `komorebi.json` configures the daemon, and how the NixOS module runs the daemon. It
grows with the code: each part of the daemon that is added has its place here.
a stack hides its windows, how the window manager follows a monitor that changes and sets the
desktop in order after a sleep or a lock, how a message on the daemon's socket is answered, how
subscribers are notified of all three, what the orders a message gives do, what a drag with the
pointer does, how the compositor is asked to draw the borders, how `komorebi.json` configures the
daemon, and how the NixOS module runs the daemon. It grows with the code: each part of the daemon
that is added has its place here.
## Vocabulary
@@ -70,7 +71,8 @@ grows with the code: each part of the daemon that is added has its place here.
- **Subscriber**: a program that listens on a socket of its own in [`DATA_DIR`] and has
asked the daemon, by the name of that socket, to send it notifications.
- **Monitor notification**: what the KWin backend sends the monitor reconciliator when the
outputs change ([`MonitorNotification`]).
outputs change, and what [`kwin::power`] sends it when the machine wakes or the screen is
unlocked ([`MonitorNotification`]).
- **Notification**: what a subscriber is sent after each event, each message and each change
of the monitors the window manager handles: the event, the message or the change, and the
state after it ([`Notification`]).
@@ -379,6 +381,36 @@ and finds nothing. While the user drags a window nothing moves, since the end of
where that window goes: the method returns at once while [`WindowManager::pending_move_op`] or
[`WindowManager::pending_resize_op`] holds a drag.
## 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, and the monitors can come back in another
order or size. None of it changes komorebi's state, but each can leave the desktop other than
komorebi left it. [`kwin::power`] watches for the two ends of it and sends a
[`MonitorNotification::Wake`] to the monitor reconciliator:
- `PrepareForSleep` of `org.freedesktop.login1.Manager` on the system bus, with `false`, 2.5
seconds after it. The match rule names `org.freedesktop.login1` as the sender, so that only
logind is believed.
- `ActiveChanged` of `org.freedesktop.ScreenSaver` on the session bus, with `false`, which KDE's
lock screen sends when it goes, 0.6 seconds after it, from any sender.
Going to sleep and locking the screen only log. A session with no lock screen or a machine with
no system bus has nothing to watch, and the daemon logs that and runs. The watchers are tasks of
the runtime of the KWin backend, which [`kwin::connect`] starts with the connection.
[`wake_up`] is what the reconciliator does for a `Wake`: it does what it does for a change of the
set of monitors ([`reconcile_display_change`]), reads the size and work area of every monitor
([`update_monitor_work_areas`]), follows the windows to the monitors they are on
([`WindowManager::follow_windows_to_their_monitors`]), and tiles every monitor
([`WindowManager::retile_all`]). A paused window manager is left alone.
The lock screen is a window of KWin's, with the class `kwin_wayland`, on every desktop and in no
task list, pager or switcher, and the bridge does not report it as an application window, so the
table never has a tile for it. The bridge also compares KWin's active window with the one it
last announced once a second, and announces the active one when they differ, since the window
that is active again after the screen is unlocked is not always announced.
## The command socket
The daemon takes messages on a Unix socket, `komorebi.sock` in [`DATA_DIR`]
@@ -1125,12 +1157,14 @@ module's unit.
- [`monitor_reconciliator`](../../komorebi/src/monitor_reconciliator.rs): the channel the KWin
backend sends [`MonitorNotification`]s on, [`listen_for_notifications`], the thread that
takes them, and [`reconcile_display_change`], which brings the window manager's monitors in
line with the outputs;
line with the outputs, and [`wake_up`], which puts the desktop in order after a wake;
[`reconcile`](../../komorebi/src/reconcile.rs): moves what was on a monitor that went to the
monitors that are left, and every container whose window is on another monitor to that
monitor.
- [`kwin`](../../komorebi/src/kwin/mod.rs): the KWin backend, its D-Bus service and the held
call; [`kwin::state`](../../komorebi/src/kwin/state.rs), the table;
[`kwin::power`](../../komorebi/src/kwin/power.rs), the watchers of logind's sleep signal and the
lock screen's;
[`kwin::kwinrc`](../../komorebi/src/kwin/kwinrc.rs), the editor of the user's `kwinrc`, which
keeps the values it changes and puts them back; the parking desktop is made, swept and removed
in `kwin` and tracked in `kwin::state`; and
@@ -1305,6 +1339,13 @@ the fake. The window manager is its only caller, through [`backend`]. The contra
| A monitor komorebi has not seen gets workspaces of its own | framework path: [`load_monitor_information`] adds the monitors the window manager does not have | `a_monitor_that_was_never_there_gets_windows_of_its_own` | none |
| When KWin reports no output, the window manager keeps the monitors it has | framework path: [`reconcile_display_change`] returns before it removes any | `a_desktop_that_reports_no_monitor_is_left_as_it_was` | none |
| A monitor is only removed once its windows are on the monitors that are left | framework path: [`reconcile_display_change`] evacuates every monitor that went before it removes any | `the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left` | none |
| Waking up and unlocking each make the window manager look at the desktop again, once, after a delay, and going to sleep and locking do not | framework path: [`kwin::power`] sends a `Wake` after each of the two signals' `false`, and nothing for `true` | `waking_and_unlocking_make_the_window_manager_look_again_and_going_to_sleep_and_locking_do_not` (private buses) | none |
| Only logind is believed about the machine going to sleep | framework path: the match rule of the system bus names `org.freedesktop.login1` as the sender | the same test sends as another connection too | none |
| komorebi runs without a system bus or a lock screen | framework path: [`kwin::power`] logs that it is not watching, and a session with no lock screen sends no signal | none | none |
| On waking, the windows are where their tiles are, and a monitor that went while asleep is dealt with | framework path: [`wake_up`] reconciles the monitors, follows the windows and tiles every monitor | `windows_that_did_not_get_where_they_were_sent_while_the_screen_was_off_are_put_there_on_waking`, `a_monitor_unplugged_while_the_machine_slept_is_dealt_with_on_waking` | none |
| A paused window manager moves no window on waking | framework path: [`wake_up`] returns first when the window manager is paused | `a_paused_window_manager_is_left_alone_on_waking` | none |
| The lock screen has no tile | convention in the bridge: a window of the class `kwin_wayland` is not an application window | `dialogs, the lock screen and windows nobody can switch to are not application windows`, `vm-lock-sleep` ("the lock screen is not one") | none |
| komorebi's idea of the focus follows KWin's after the screen is unlocked | convention in the bridge: it announces KWin's active window when it is not the one it last announced, once a second | `the active window is announced when KWin has another than the one last announced, once`, `vm-lock-sleep` ("its idea of the focus is KWin's") | none |
| A subscriber that wants only changes of the state is sent every change of the monitors | framework path: [`notify_subscribers`] treats a monitor notification as an override of the filter | `a_change_of_the_monitors_is_sent_to_a_subscriber_that_filters_state_changes_too` | none |
| Moving the focus moves no window | test | `focus_gives_the_focus_to_the_window_on_that_side_and_moves_nothing`, `vm-core-e2e` ("moving the focus moved no window") | none |
| A subscriber that is gone is dropped, not retried | test | `a_subscriber_that_is_gone_is_dropped_and_not_tried_again` | none |
@@ -1537,6 +1578,11 @@ A window is shown, and ends up on a tile:
output, against the fake;
[`nix/tests/vm/suites/hotplug`](../../nix/tests/vm/suites/hotplug/test.py), both against a real
KWin with two outputs.
- [`komorebi/tests/wake.rs`](../../komorebi/tests/wake.rs): the desktop after a wake, against the
fake; [`komorebi/tests/power.rs`](../../komorebi/tests/power.rs), the signals of logind and
of the lock screen, on private buses;
[`nix/tests/vm/suites/lock-sleep`](../../nix/tests/vm/suites/lock-sleep/test.py), the lock
screen of a real Plasma session.
- [`komorebi/tests/workspaces.rs`](../../komorebi/tests/workspaces.rs): workspaces as the
fake's desktops;
[`komorebi/tests/workspace_commands.rs`](../../komorebi/tests/workspace_commands.rs), naming,
@@ -1775,6 +1821,11 @@ A window is shown, and ends up on a tile:
[`MonitorNotification::DisplayConnectionChange`]: ../../komorebi/src/monitor_reconciliator.rs
[`reconcile_display_change`]: ../../komorebi/src/monitor_reconciliator.rs
[`insert_in_monitor_cache`]: ../../komorebi/src/monitor_reconciliator.rs
[`kwin::power`]: ../../komorebi/src/kwin/power.rs
[`MonitorNotification::Wake`]: ../../komorebi/src/monitor_reconciliator.rs
[`wake_up`]: ../../komorebi/src/monitor_reconciliator.rs
[`WindowManager::retile_all`]: ../../komorebi/src/window_manager.rs
[`kwin::connect`]: ../../komorebi/src/kwin/mod.rs
[`WindowManager::evacuate_monitor`]: ../../komorebi/src/reconcile.rs
[`WindowManager::follow_windows_to_their_monitors`]: ../../komorebi/src/reconcile.rs
[`settle_later`]: ../../komorebi/src/monitor_reconciliator.rs
+20
View File
@@ -624,3 +624,23 @@ reports on another output is handled the same way, looking at every window and n
one, since when one has moved others may have too. komorebi tiles at once the windows it knows
about when the monitors change, such as those of an output that went; the ones KWin moves
afterwards are tiled up to 1.5 seconds later.
## Why does komorebi look at the desktop again when the machine wakes and the screen is unlocked?
Because nothing komorebi asked for while the screen was locked or the machine asleep was done.
In that time KWin sends its clients no frame callbacks, so a window that was asked to move did
not, a window that something else moved stayed where it was moved, and the monitors can come
back in another order or at another size. Neither an unlock nor a wake is an event about a
window, so komorebi listens for the two signals that say it happened, logind's `PrepareForSleep`
and the lock screen's `ActiveChanged`, and then reads the monitors, finds where every window is,
and puts each in its tile. It waits first: 0.6 seconds after an unlock, because the lock screen
goes before the windows can be seen, and 2.5 seconds after a wake, because the outputs are found
again one after another and the compositor moves windows about as they are. Going to sleep and
locking the screen change nothing, so komorebi does nothing for them.
## Why does the bridge compare the active window with the last one it announced?
Because after the screen is unlocked the window that is active again is not always announced,
and komorebi's idea of the focus would stay the one it had. Once a second the bridge compares the
window KWin has active with the one it last announced, and announces the active one when they
differ. It costs a comparison of two ids a second.
+30 -4
View File
@@ -126,8 +126,16 @@ the windows and outputs it finds, and from then on every window that opens, clos
focus, is minimized or brought back, moves or changes size, the start and the end of every drag
of a window by the user, and every change of the outputs (one that changes resolution or scale,
and the room a panel leaves). KWin has no signal for that room, so the script looks at the
outputs again when a dock window opens, closes or moves, and once a second. The daemon waits up
to 10 seconds for the first report and does not start without it.
outputs again when a dock window opens, closes or moves, and once a second; it also compares
KWin's active window with the one it last reported once a second. The daemon waits up to 10
seconds for the first report and does not start without it.
It also watches for the machine waking up and the screen being unlocked, with two signals that
are not KWin's ([D-Bus](#d-bus)): logind's `PrepareForSleep` on the system bus, and the lock
screen's `ActiveChanged` on the session bus. Either one makes it look at the monitors and at
where every window is again, after a delay (2.5 seconds after a wake, 0.6 after an unlock). Where
there is no system bus, as in a container, it logs that it is not watching for sleep, and
carries on.
Before it loads the script, it turns on KWin's per-output virtual desktops in the user's
`kwinrc` (`[Windows] PerOutputVirtualDesktops=true`), unless they are on, and asks KWin to
@@ -191,8 +199,9 @@ directory keeps the user's value), or the session ends.
### A headless KWin
`kwin_wayland --virtual` behaves like the KWin of a session for everything komorebi does:
the VM tests run the daemon against one, on a private session bus of its own. It cannot draw
`kwin_wayland --virtual` behaves like the KWin of a session for everything komorebi does but
the lock screen, which it has none of (`vm-lock-sleep` runs in a Plasma session): the VM tests
run the daemon against one, on a private session bus of its own. It cannot draw
(see [Rendering](#rendering-in-virtual-machines-and-over-rdp)), so nothing that depends on
pixels can be tested against it. Started with `--no-global-shortcuts`, as the VM tests start
it, KWin does not own `org.kde.kglobalaccel`, and the pointer does not follow the focus; the
@@ -238,6 +247,16 @@ What komorebi calls:
| `org.kde.KWin` | `/VirtualDesktopManager` | `org.kde.KWin.VirtualDesktopManager` | the properties `desktops` and `count`, `createDesktop(us)`, `setDesktopName(ss)`, `removeDesktop(s)` | when a workspace has no desktop, and at start; `createDesktop` also for the parking desktop, the first time a window is hidden, and `removeDesktop` for it when komorebi stops and for one a killed run left |
| `org.kde.plasmashell` | `/PlasmaShell` | `org.kde.PlasmaShell` | `evaluateScript(s) -> s`, `wallpaper(u) -> a{sv}`, `setWallpaper(sa{sv}u)` | when a workspace with a wallpaper comes into view ([The Plasma shell](#the-plasma-shell)) |
What komorebi listens to, for the machine sleeping and the screen being locked:
| Bus | Sender | Interface | Signal | Meaning |
|---|---|---|---|---|
| system | `org.freedesktop.login1` | `org.freedesktop.login1.Manager` | `PrepareForSleep(b)` | the machine is going to sleep (true) or has woken up (false) |
| session | any | `org.freedesktop.ScreenSaver` | `ActiveChanged(b)` | the lock screen came up (true) or went away (false) |
Only logind is believed about sleep. The lock screen's signal is taken from any sender on the
session bus.
The property `desktops` is introspected as `a(iss)`, a position, an id and a name for each
desktop, and the value on the wire is `a(uss)`: komorebi reads it as that. It is read uncached,
since a read right after `createDesktop` has to see the new desktop.
@@ -448,6 +467,13 @@ with two outputs of 1920x1080:
it was on. KWin raises `outputChanged` for it, which `vm-hotplug` shows by moving windows with
`kdotool windowmove`, as a shortcut or the window menu would.
## Sleep and lock
At time of writing (2026-10-27), a real suspend and resume is not tested: the QEMU guests the VM
tests run in have no way to wake from one. The signals are tested on private buses
(`komorebi/tests/power.rs`), and the lock screen against a real KWin in a Plasma session
(`vm-lock-sleep`, which locks and unlocks with `loginctl lock-session` and `unlock-session`).
## How long a retile takes
Measured by the VM test `vm-latency` at time of writing (2026-10-27), in a headless KWin 6.8.1
+3 -1
View File
@@ -68,7 +68,9 @@ to the window under the pointer, [Themes](usage/themes.md) how a theme gives the
colors, and [Wallpapers](usage/wallpapers.md) how a monitor or a workspace shows a picture of its own,
and what it does to the borders.
[Changing monitors](usage/changing-monitors.md) explains what komorebi does when a monitor
changes size, is unplugged or is plugged in.
changes size, is unplugged or is plugged in, and
[Sleeping and locking](usage/sleeping-and-locking.md) what it does when the machine wakes up and
the screen is unlocked.
[Subscribing to notifications](usage/subscribing-to-notifications.md) explains how a program
such as a status bar follows what komorebi does. The [CLI reference](cli/start.md) has a page
for every `komorebic` command, generated from its help.
+45
View File
@@ -0,0 +1,45 @@
# Sleeping and locking
komorebi looks at the desktop again when the machine wakes up and when the screen is unlocked.
There is nothing to configure and no command to run.
## Why
While the screen is locked or the machine is asleep, KWin sends its clients no frame callbacks,
so the windows do not draw and nothing komorebi asked for in that time is done: a window moved
by something else stays where it was moved, and a window that was asked to move does not. The
monitors can come back in another order, or at another size, or not all of them. A window that
is where komorebi did not put it is not in its tile.
## What komorebi does
- **The screen is unlocked.** The lock screen is a window of KWin's, on every desktop and in no
task list, and komorebi does not manage windows like that: it has no tile, and nothing is
moved about while the screen is locked. Locking the screen does nothing. A moment after it is
unlocked (0.6 seconds: the lock screen goes before the windows can be seen), komorebi looks
at the desktop.
- **The machine wakes up.** komorebi hears it from logind (`PrepareForSleep`, which is false when
the machine is awake). Going to sleep does nothing. A moment after it wakes (2.5 seconds: the
outputs are found again one after another, and the compositor moves windows about as they
are), komorebi looks at the desktop.
Looking at the desktop is the same for both. komorebi reads the monitors again, as when one is
unplugged or plugged in ([Changing monitors](changing-monitors.md)), reads the size and work
area of every monitor, finds every window that is on another monitor than the one it has it on,
and tiles every monitor. Nothing is done while komorebi is paused
([`komorebic toggle-pause`](../cli/toggle-pause.md)).
The bridge compares the window KWin has active with the one it last told komorebi about once a
second, because the window that is active again after an unlock is not always announced, and
komorebi's idea of the focus follows KWin's.
## What is not tested
A real suspend and a real resume are not tested: the virtual machines the tests run in have no
way to wake from one. The signals komorebi waits for are tested on private buses, and the lock
screen against a real KWin.
## Following it from a program
A [subscriber](subscribing-to-notifications.md) is sent a notification, whose `event` is `"Wake"`,
when komorebi has looked at the desktop after a wake or an unlock.
+3 -1
View File
@@ -41,7 +41,9 @@ A notification has two fields:
`{"type":"Retile"}`. A change of the monitors is the name of the change with its content:
`{"Resize":0}` for a monitor that changed size, with the id of the monitor, or 0 when KWin
does not say which, and `{"DisplayConnectionChange":0}` for a monitor that was unplugged or
plugged in. [Changing monitors](changing-monitors.md) says when each is sent.
plugged in, and `"Wake"` for the machine waking up or the screen being unlocked.
[Changing monitors](changing-monitors.md) and [Sleeping and locking](sleeping-and-locking.md)
say when each is sent.
- `state`: the state of the window manager after it, with the same fields as what
[`komorebic state`](../cli/state.md) prints, on one line.
+3
View File
@@ -333,6 +333,9 @@
pkgs.just
pkgs.ripgrep
# the tests of the sleep and lock signals start private buses (`kwin::power`)
pkgs.dbus
# the bridge's type check and tests (`just check-js`)
pkgs.nodejs
pkgs.typescript
+11
View File
@@ -504,6 +504,16 @@ function announceActive(w) {
}
workspace.windowActivated.connect(announceActive);
// The active window as KWin has it, against the one last announced. They part ways when the screen is
// locked and unlocked (the window that is active again is not always announced), and it costs nothing
// to say so once a second.
function checkActive() {
var now = workspace.activeWindow ? wid(workspace.activeWindow) : null;
if (now !== lastActive) {
announceActive(workspace.activeWindow);
}
}
workspace.screensChanged.connect(function () {
announceScreens();
// the panel follows a new resolution a moment later
@@ -659,5 +669,6 @@ watchdog.timeout.connect(function () {
}
announceScreens();
announceDesktops();
checkActive();
});
watchdog.start();
+24
View File
@@ -280,6 +280,10 @@ function kwin({
}
workspace.windowRemoved.emit(w);
},
// KWin gives a window the focus and nothing is raised, as when the screen is unlocked
activateQuietly: (w) => {
activeWindow = w;
},
// the daemon answers the bridge's last `Poll` with these batches
answer: (batches) => k.reply(JSON.stringify(batches)),
// somebody makes another desktop (System Settings, the pager)
@@ -576,6 +580,26 @@ test("no window having the focus is an activation with no id", () => {
assert.deepStrictEqual(k.events().at(-1), { type: "activated", id: null, seq: 2 });
});
test("the active window is announced when KWin has another than the one last announced, once", () => {
const [a, b] = [window({ id: "{a}" }), window({ id: "{b}" })];
const k = kwin({ windows: [a, b], active: a });
k.watchdog().timeout.emit();
assert.equal(k.events().length, 1, "KWin and the bridge agree");
// the screen is unlocked, and the window that is active again was not announced
k.activateQuietly(b);
k.watchdog().timeout.emit();
k.watchdog().timeout.emit();
assert.deepStrictEqual(k.events().slice(1), [{ type: "activated", id: "{b}", seq: 2 }]);
k.activateQuietly(null);
k.watchdog().timeout.emit();
assert.deepStrictEqual(k.events().at(-1), { type: "activated", id: null, seq: 3 });
});
test("dialogs, the lock screen and windows nobody can switch to are not application windows", () => {
const windows = [
window({ id: "{app}" }),
+6
View File
@@ -39,6 +39,10 @@
//! panel leaves. A change of an output's size or of its work area is sent to
//! [`monitor_reconciliator`], which reads the monitors again.
//!
//! [`power`] watches for the machine waking up and the screen being unlocked, and sends those to
//! the reconciliator too. The script compares KWin's active window with the one it last announced
//! once a second, so that the focus is kept in step when the screen is locked and unlocked.
//!
//! Workspaces are KWin's own virtual desktops, per output: workspace `i` is desktop `i`, and each
//! output shows a desktop of its own, which KWin does once its per-output virtual desktops are
//! turned on. [`connect`] turns them on in the user's `kwinrc` (the exit puts the user's setting
@@ -75,6 +79,7 @@
mod effects;
mod kwinrc;
pub mod power;
pub mod state;
mod wallpaper;
@@ -440,6 +445,7 @@ async fn start(shared: Arc<Shared>) -> eyre::Result<()> {
})?;
*shared.connection.lock() = Some(connection.clone());
power::watch(connection.clone()).await;
enable_per_output_desktops(&shared, &connection).await;
load_script(&connection).await
}
+149
View File
@@ -0,0 +1,149 @@
//! The machine going to sleep and waking up, and the screen being locked and unlocked.
//!
//! None of it changes komorebi's state, but each can leave the desktop other than komorebi left
//! it. While the screen is locked or asleep the compositor sends the clients no frame
//! callbacks, so nothing komorebi asked for in that time was done, and the monitors can come back
//! in another order or at another size. When it is over, this module sends
//! [`MonitorNotification::Wake`], and the monitor reconciliator's
//! [`wake_up`](crate::monitor_reconciliator::wake_up) looks at the monitors again and puts every
//! window in its tile.
//!
//! Two signals are watched:
//!
//! - `PrepareForSleep` of `org.freedesktop.login1.Manager`, from logind on the system bus: `false`
//! is the machine waking up.
//! - `ActiveChanged` of `org.freedesktop.ScreenSaver`, from KDE's lock screen on the session bus:
//! `false` is the screen being unlocked.
//!
//! # What is guaranteed, and by what
//!
//! | Rule | Enforced by | Proof |
//! |---|---|---|
//! | Waking up and unlocking each send one `Wake`, after a delay; going to sleep and locking send nothing | the two watchers | `tests/power.rs::waking_and_unlocking_make_the_window_manager_look_again_and_going_to_sleep_and_locking_do_not` |
//! | Only logind is believed about sleep | the match rule names `org.freedesktop.login1` as the sender | no test (the test sends as logind only) |
//! | komorebi runs without a system bus or a lock screen | [`watch_on`] logs that it is not watching for sleep; a session without a lock screen sends no signal | no test |
//!
//! The lock screen's signal is taken from any sender on the session bus.
//!
//! A real suspend and resume is not tested: at time of writing (2026-10-27) the QEMU guests have
//! no way to wake from one. The signals are tested on private buses, and the lock screen against
//! a real KWin.
#![deny(missing_docs)]
use crate::monitor_reconciliator;
use crate::monitor_reconciliator::MonitorNotification;
use futures_util::StreamExt;
use std::time::Duration;
use zbus::Connection;
use zbus::MatchRule;
use zbus::MessageStream;
use zbus::message::Type;
/// How long after the screen is unlocked komorebi waits before it looks: the lock screen goes
/// before the windows can be seen.
const AFTER_UNLOCK: Duration = Duration::from_millis(600);
/// How long after the machine wakes komorebi waits before it looks: the outputs are found again
/// one after another, and the compositor moves windows about as they are.
const AFTER_WAKE: Duration = Duration::from_millis(2500);
/// Watches for the machine waking up and the screen being unlocked, for as long as komorebi runs.
///
/// The watchers are spawned on the tokio runtime this is called on, and this returns once they
/// are spawned. Neither signal has to come: a session without a lock screen, or a container with
/// no system bus, has nothing to watch, and the missing system bus is logged.
pub async fn watch(session: Connection) {
watch_on(session, Connection::system().await.ok()).await;
}
/// Watches as [`watch`] does, on the buses it is given: the session bus for the lock screen, and
/// the system bus for logind when there is one.
///
/// A test passes private buses, with a connection standing in for logind and one for the lock
/// screen; `tests/power.rs` does.
pub async fn watch_on(session: Connection, system: Option<Connection>) {
tokio::spawn(screen_lock(session));
match system {
Some(system) => {
tokio::spawn(sleep(system));
}
None => tracing::debug!("not watching for the machine going to sleep: no system bus"),
}
}
/// Sends a `Wake` [`AFTER_UNLOCK`] each time the lock screen goes, until the bus closes.
async fn screen_lock(session: Connection) {
let result = async {
// KDE's lock screen says whether it is up, on KWin's name for the freedesktop interface
let rule = MatchRule::builder()
.msg_type(Type::Signal)
.interface("org.freedesktop.ScreenSaver")?
.member("ActiveChanged")?
.build();
let mut stream = MessageStream::for_match_rule(rule, &session, None).await?;
while let Some(message) = stream.next().await {
let Ok(message) = message else { continue };
let Ok(locked) = message.body().deserialize::<bool>() else {
continue;
};
if locked {
tracing::info!("the screen was locked");
} else {
tracing::info!("the screen was unlocked");
wake_after(AFTER_UNLOCK).await;
}
}
zbus::Result::Ok(())
}
.await;
if let Err(error) = result {
tracing::debug!("not watching the lock screen: {error}");
}
}
/// Sends a `Wake` [`AFTER_WAKE`] each time logind signals that the machine woke up, until the bus
/// closes.
async fn sleep(system: Connection) {
let result = async {
let rule = MatchRule::builder()
.msg_type(Type::Signal)
.sender("org.freedesktop.login1")?
.interface("org.freedesktop.login1.Manager")?
.member("PrepareForSleep")?
.build();
let mut stream = MessageStream::for_match_rule(rule, &system, None).await?;
while let Some(message) = stream.next().await {
let Ok(message) = message else { continue };
let Ok(going_to_sleep) = message.body().deserialize::<bool>() else {
continue;
};
if going_to_sleep {
tracing::info!("the machine is going to sleep");
} else {
tracing::info!("the machine woke up");
wake_after(AFTER_WAKE).await;
}
}
zbus::Result::Ok(())
}
.await;
if let Err(error) = result {
tracing::debug!("not watching for the machine going to sleep: {error}");
}
}
/// Waits, then sends a `Wake` to the monitor reconciliator.
async fn wake_after(delay: Duration) {
tokio::time::sleep(delay).await;
monitor_reconciliator::send_notification(MonitorNotification::Wake);
}
+43 -8
View File
@@ -1,8 +1,9 @@
//! Keeping komorebi's monitors in step with the compositor's outputs, as they change size and are
//! unplugged and plugged back.
//! Keeping komorebi's monitors in step with the compositor's outputs, as they change size, are
//! unplugged and plugged back, and come back from a sleep or a lock.
//!
//! The KWin backend sends a [`MonitorNotification`] when KWin's list of outputs changes. The
//! thread [`listen_for_notifications`] starts takes them one at a time:
//! The KWin backend sends a [`MonitorNotification`] when KWin's list of outputs changes, and
//! [`kwin::power`](crate::kwin::power) sends one when the machine wakes or the screen is unlocked.
//! The thread [`listen_for_notifications`] starts takes them one at a time:
//!
//! - [`MonitorNotification::Resize`]: the size and work area of every monitor are read again
//! ([`desktop::update_monitor_work_areas`]), and the focused workspace of the focused monitor
@@ -10,6 +11,7 @@
//! - [`MonitorNotification::DisplayConnectionChange`]: [`reconcile_display_change`] removes the
//! monitors that went, once what was on them is on the monitors that are left, and adds the ones
//! that came.
//! - [`MonitorNotification::Wake`]: [`wake_up`] does both, and puts every window in its tile.
//!
//! A monitor that is unplugged is kept, with its workspaces and settings, and a monitor plugged in
//! with the same serial number id gets them back. The workspace rules of a monitor that goes are
@@ -21,8 +23,8 @@
//! ([`NotificationEvent::Monitor`]), and the backend is asked for the look again
//! ([`border_manager::send_notification`]), which it does nothing about unless a setting changed.
//!
//! Covered by `tests/monitor_resize.rs`, `tests/hotplug.rs` and `tests/follow_output.rs` against the
//! fake backend, and by the `vm-hotplug` VM suite.
//! Covered by `tests/monitor_resize.rs`, `tests/hotplug.rs`, `tests/follow_output.rs` and
//! `tests/wake.rs` against the fake backend, and by the `vm-hotplug` and `vm-lock-sleep` VM suites.
use crate::DISPLAY_INDEX_PREFERENCES;
use crate::Notification;
@@ -60,6 +62,9 @@ pub enum MonitorNotification {
Resize(u32),
/// A monitor was connected or disconnected: the KWin backend sends 0
DisplayConnectionChange(u32),
/// The machine woke up, or the screen was unlocked: look at the monitors and at where the
/// windows are, and put each where it belongs
Wake,
}
static CHANNEL: OnceLock<(Sender<MonitorNotification>, Receiver<MonitorNotification>)> =
@@ -489,6 +494,29 @@ pub fn reconcile_display_change(wm: &mut WindowManager) -> color_eyre::Result<()
Ok(())
}
/// The desktop after a sleep or a lock: the monitors as they are now, the windows on the monitors
/// they are on, and every one of them where its tile is
///
/// Nothing is done while komorebi is paused.
///
/// # Errors
///
/// Returns the errors of [`reconcile_display_change`] and of
/// [`WindowManager::follow_windows_to_their_monitors`], and an error if the monitors cannot be
/// read again or the windows cannot be tiled.
pub fn wake_up(wm: &mut WindowManager) -> color_eyre::Result<()> {
if wm.is_paused {
return Ok(());
}
reconcile_display_change(wm)?;
desktop::update_monitor_work_areas(wm)?;
wm.follow_windows_to_their_monitors()?;
wm.retile_all(true)?;
Ok(())
}
/// Takes the notifications one at a time, for as long as komorebi runs.
///
/// Each is dealt with as the [module docs](self) say, and a moment later the windows are followed
@@ -499,8 +527,8 @@ pub fn reconcile_display_change(wm: &mut WindowManager) -> color_eyre::Result<()
///
/// Returns an error if [`reconcile_display_change`] fails for a
/// [`MonitorNotification::DisplayConnectionChange`], or if the subscribers cannot be notified;
/// [`listen_for_notifications`] then calls it again. The errors of a
/// [`MonitorNotification::Resize`] are logged.
/// [`listen_for_notifications`] then calls it again. The errors of the other notifications are
/// logged.
pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result<()> {
tracing::info!("listening");
let receiver = event_rx();
@@ -519,6 +547,13 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
tracing::error!("failed to update monitor work areas: {error}");
}
}
MonitorNotification::Wake => {
tracing::debug!("handling wake notification");
let mut wm = wm.lock();
if let Err(error) = wake_up(&mut wm) {
tracing::error!("could not put the desktop in order after waking: {error}");
}
}
MonitorNotification::DisplayConnectionChange(_) => {
tracing::debug!("handling display connection change notification");
let mut wm = wm.lock();
+175
View File
@@ -0,0 +1,175 @@
//! The machine waking up and the screen being unlocked reach komorebi as the signals logind and the
//! lock screen send, and each makes the window manager look at the desktop again.
//!
//! The buses here are private ones, with a test standing in for logind and for KDE's lock screen.
//! It needs `dbus-daemon`, and says nothing when there is none.
use komorebi::kwin::power;
use komorebi::monitor_reconciliator;
use komorebi::monitor_reconciliator::MonitorNotification;
use std::io::BufRead;
use std::io::BufReader;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use zbus::Connection;
struct Bus {
daemon: Child,
address: String,
}
impl Bus {
fn start() -> Option<Self> {
let mut daemon = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.ok()?;
let mut address = String::new();
BufReader::new(daemon.stdout.take()?)
.read_line(&mut address)
.ok()?;
Some(Self {
daemon,
address: address.trim().to_string(),
})
}
async fn connect(&self, name: Option<&str>) -> Connection {
let mut builder = zbus::connection::Builder::address(self.address.as_str()).unwrap();
if let Some(name) = name {
builder = builder.name(name.to_string()).unwrap();
}
builder.build().await.unwrap()
}
}
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.daemon.kill();
let _ = self.daemon.wait();
}
}
fn woke(timeout: Duration) -> bool {
matches!(
monitor_reconciliator::channel().1.recv_timeout(timeout),
Ok(MonitorNotification::Wake)
)
}
#[test]
fn waking_and_unlocking_make_the_window_manager_look_again_and_going_to_sleep_and_locking_do_not() {
let (Some(session_bus), Some(system_bus)) = (Bus::start(), Bus::start()) else {
eprintln!("no dbus-daemon: skipping");
return;
};
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let watcher_session = session_bus.connect(None).await;
let watcher_system = system_bus.connect(None).await;
power::watch_on(watcher_session, Some(watcher_system)).await;
// logind, and the lock screen
let logind = system_bus.connect(Some("org.freedesktop.login1")).await;
let screen_saver = session_bus
.connect(Some("org.freedesktop.ScreenSaver"))
.await;
tokio::time::sleep(Duration::from_millis(500)).await;
let emit = |connection: Connection,
path: &'static str,
interface: &'static str,
member: &'static str,
on: bool| async move {
connection
.emit_signal(None::<&str>, path, interface, member, &(on,))
.await
.unwrap();
};
// going to sleep is nothing to do yet: nothing has changed
emit(
logind.clone(),
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForSleep",
true,
)
.await;
// and neither is the lock screen coming up
emit(
screen_saver.clone(),
"/ScreenSaver",
"org.freedesktop.ScreenSaver",
"ActiveChanged",
true,
)
.await;
assert!(
!tokio::task::spawn_blocking(|| woke(Duration::from_millis(1200)))
.await
.unwrap(),
"going to sleep, or locking, made it look"
);
// the lock screen goes
emit(
screen_saver.clone(),
"/ScreenSaver",
"org.freedesktop.ScreenSaver",
"ActiveChanged",
false,
)
.await;
assert!(
tokio::task::spawn_blocking(|| woke(Duration::from_secs(4)))
.await
.unwrap(),
"unlocking did not make it look"
);
// the machine wakes (a moment later: the monitors are found one at a time)
emit(
logind.clone(),
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForSleep",
false,
)
.await;
assert!(
tokio::task::spawn_blocking(|| woke(Duration::from_secs(6)))
.await
.unwrap(),
"waking did not make it look"
);
// somebody that is not logind saying the same thing is not believed
let impostor = system_bus.connect(None).await;
emit(
impostor,
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForSleep",
false,
)
.await;
assert!(
!tokio::task::spawn_blocking(|| woke(Duration::from_secs(4)))
.await
.unwrap(),
"a signal from something other than logind was taken for logind's"
);
});
}
+179
View File
@@ -0,0 +1,179 @@
//! The desktop after the machine wakes up or the screen is unlocked, against an in-memory desktop.
//!
//! While the screen is locked or asleep, what komorebi asks for is not done and the monitors can
//! change. On waking komorebi looks at the monitors again and puts every window in its tile.
//!
//! Window ids are in the 50_000s; the monitor cache is process-wide, so the tests that unplug a
//! monitor use names of their own.
use komorebi::backend::fake::FakeBackend;
use komorebi::backend::with_backend;
use komorebi::core::Rect;
use komorebi::monitor_reconciliator::wake_up;
use komorebi::window::Window;
use komorebi::window_manager::WindowManager;
use komorebi::window_manager_event::KWinNotification;
use komorebi::window_manager_event::SystemNotification;
use komorebi::window_manager_event::WindowManagerEvent;
use std::sync::Arc;
fn area(left: i32) -> Rect {
Rect {
left,
top: 0,
right: 1920,
bottom: 1080,
}
}
fn work_area(left: i32) -> Rect {
Rect {
left,
top: 0,
right: 1920,
bottom: 1050,
}
}
fn desktop_of(names: &[&str]) -> (Arc<FakeBackend>, WindowManager) {
let fake = Arc::new(FakeBackend::new());
for (i, name) in names.iter().enumerate() {
fake.add_monitor(
50 + i as u32,
name,
area(1920 * i as i32),
work_area(1920 * i as i32),
);
}
let (_sender, receiver) = crossbeam_channel::bounded(1);
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let socket = std::path::PathBuf::from(format!(
"/tmp/kmh-{}-{}.sock",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
));
let wm = with_backend(fake.clone(), || {
let mut wm = WindowManager::new(receiver, Some(socket)).unwrap();
wm.init().unwrap();
wm
});
(fake, wm)
}
fn added(id: u32) -> WindowManagerEvent {
WindowManagerEvent::Show(
SystemNotification::KWin(KWinNotification::WindowAdded),
Window::from(id),
)
}
/// where a window opens: somewhere in the area that starts at `left`
fn somewhere_in(left: i32) -> Rect {
Rect {
left: left + 200,
top: 200,
right: 800,
bottom: 600,
}
}
fn open_on(fake: &Arc<FakeBackend>, wm: &mut WindowManager, id: u32, left: i32) {
fake.open_window(id, "foot", "foot", &format!("terminal {id}"));
fake.move_window_by_user(id, somewhere_in(left));
with_backend(fake.clone(), || wm.process_event(added(id))).unwrap();
}
fn containers_on(wm: &WindowManager, monitor: usize) -> usize {
wm.monitors()[monitor]
.focused_workspace()
.map_or(0, |w| w.containers().len())
}
fn inside(rect: &Rect, area: &Rect) -> bool {
rect.left >= area.left
&& rect.top >= area.top
&& rect.left + rect.right <= area.left + area.right
&& rect.top + rect.bottom <= area.top + area.bottom
}
fn overlap(a: &Rect, b: &Rect) -> bool {
a.left < b.left + b.right
&& b.left < a.left + a.right
&& a.top < b.top + b.bottom
&& b.top < a.top + a.bottom
}
fn assert_tiled_in(fake: &FakeBackend, ids: &[u32], area: &Rect) {
let rects: Vec<Rect> = ids.iter().map(|id| fake.rect_of(*id).unwrap()).collect();
for (i, a) in rects.iter().enumerate() {
assert!(inside(a, area), "{a:?} is not inside {area:?}");
for b in &rects[i + 1..] {
assert!(!overlap(a, b), "{a:?} and {b:?} overlap");
}
}
}
#[test]
fn windows_that_did_not_get_where_they_were_sent_while_the_screen_was_off_are_put_there_on_waking()
{
let (fake, mut wm) = desktop_of(&["Test-J"]);
for id in [50_501, 50_502, 50_503] {
open_on(&fake, &mut wm, id, 0);
}
let tiles: Vec<Rect> = [50_501, 50_502, 50_503]
.iter()
.map(|id| fake.rect_of(*id).unwrap())
.collect();
// the screen was locked and asleep: what komorebi asked for was not done, and the windows are
// wherever the compositor had them
fake.move_window_by_user(50_502, somewhere_in(0));
fake.move_window_by_user(50_503, somewhere_in(300));
with_backend(fake.clone(), || wake_up(&mut wm)).unwrap();
let now: Vec<Rect> = [50_501, 50_502, 50_503]
.iter()
.map(|id| fake.rect_of(*id).unwrap())
.collect();
assert_eq!(tiles, now, "the windows are not in their tiles");
}
#[test]
fn a_monitor_unplugged_while_the_machine_slept_is_dealt_with_on_waking() {
let (fake, mut wm) = desktop_of(&["Test-K", "Test-L"]);
open_on(&fake, &mut wm, 50_601, 0);
open_on(&fake, &mut wm, 50_602, 1920);
// while it slept the second monitor went, and the compositor took its window to the first
fake.remove_monitor(51);
fake.move_window_by_user(50_602, somewhere_in(0));
with_backend(fake.clone(), || wake_up(&mut wm)).unwrap();
assert_eq!(wm.monitors().len(), 1);
assert_eq!(containers_on(&wm, 0), 2);
assert_tiled_in(&fake, &[50_601, 50_602], &work_area(0));
}
#[test]
fn a_paused_window_manager_is_left_alone_on_waking() {
let (fake, mut wm) = desktop_of(&["Test-M"]);
open_on(&fake, &mut wm, 50_701, 0);
let tile = fake.rect_of(50_701).unwrap();
fake.move_window_by_user(50_701, somewhere_in(300));
let moved = fake.rect_of(50_701).unwrap();
assert_ne!(tile, moved);
wm.is_paused = true;
with_backend(fake.clone(), || wake_up(&mut wm)).unwrap();
assert_eq!(
fake.rect_of(50_701),
Some(moved),
"a paused window manager moved a window"
);
}
@@ -0,0 +1,5 @@
{
tier = "slow";
kind = "session";
timeoutMinutes = 15;
}
+144
View File
@@ -0,0 +1,144 @@
# The screen being locked and unlocked in a real Plasma session, with komorebi running as the
# module's user unit. What only a real session can tell: that the lock screen is not tiled (it is a
# window of KWin's, on every desktop and in no task list), that nothing komorebi does while the
# screen is locked changes the layout, and that when the screen is unlocked komorebi looks at the
# desktop again and puts every window where its tile is, with its idea of the focus KWin's.
#
# A real suspend and resume is not tested: the QEMU guest has no way to wake from one. The signals
# komorebi waits for, logind's `PrepareForSleep` and the lock screen's `ActiveChanged`, are
# tested on private buses by `komorebi/tests/power.rs`, and the desktop it finds on waking against
# the fake by `komorebi/tests/wake.rs`.
import time
from komorebi_vm import Checks, Kde, Rect
UNIT = "komorebi.service"
TITLES = ["sl-a", "sl-b", "sl-c"]
# what a terminal's frame may be off its tile by: up to a cell of its font
CELL = 24
# the daemon's log of the day, without colors, which the unit writes
LOG = "/home/komorebi/.local/share/komorebi/komorebi_plaintext.log.*"
start_all()
machine.wait_for_unit("multi-user.target")
checks = Checks()
kde = Kde.session(machine, scale=SCALE)
def daemon_log() -> str:
return machine.execute(f"cat {LOG}")[1]
def frames() -> list[Rect]:
return [kde.rect(kde.wid(title)) for title in TITLES]
def settled_frames() -> list[Rect]:
"""The frames the windows have once they have stopped moving: the same a second apart.
A window that opens makes komorebi tile the others again, so the frames are not the tiles
until the last window has had its turn.
"""
seen: list[list[Rect]] = []
def still() -> bool:
seen.append(frames())
time.sleep(1 * SCALE)
return len(seen) >= 2 and seen[-1] == seen[-2]
kde.wait_for(still, timeout=60)
return seen[-1]
def tile_of(title: str) -> Rect:
"""The tile the daemon has for the window now, from its layout."""
workspace = kde.state()["monitors"]["elements"][0]["workspaces"]["elements"][0]
for i, container in enumerate(workspace["containers"]["elements"]):
windows = container["windows"]
if windows["elements"][windows["focused"]]["details"]["title"] == title:
tile = workspace["latest_layout"][i]
return Rect(tile["left"], tile["top"], tile["right"], tile["bottom"])
raise AssertionError(f"the daemon has no container for {title}")
def near(a: Rect, b: Rect) -> bool:
"""Whether the frame is the tile, give or take the cell of the terminal's font."""
return max(abs(a.x - b.x), abs(a.y - b.y), abs(a.w - b.w), abs(a.h - b.h)) <= CELL
def session_id() -> str:
"""The id of the user's graphical session, which is the one that gets locked."""
for line in kde.sh("loginctl list-sessions --no-legend").splitlines():
words = line.split()
if len(words) > 2 and words[2] == "komorebi":
if kde.sh(f"loginctl show-session {words[0]} -p Type --value").strip() == "wayland":
return words[0]
raise AssertionError("the user has no Wayland session")
print("== 1. komorebi runs in the session, and has three windows")
checks.check(
"the module's unit runs the daemon, which answers komorebic",
kde.wait_for(lambda: kde.run("komorebic state")[0] == 0, timeout=60),
)
# The daemon tiles the windows again when it learns of the panel's room. They are opened after that,
# so that they are tiled once. The wait is not a check: a session may never take the room, and it
# ends after a while.
kde.wait_for_panel_room()
for title in TITLES:
kde.spawn(f"foot -T {title} sleep 3010")
kde.wait_for(lambda title=title: kde.wid(title) != "", timeout=60)
checks.check("komorebi manages the three", kde.wait_for(lambda: kde.managed(0) == 3, timeout=30))
checks.check(
"they are tiled: none is on another",
kde.wait_for(
lambda: not any(a.overlaps(b) for i, a in enumerate(frames()) for b in frames()[i + 1 :])
),
)
tiles = settled_frames()
sid = session_id()
print(f" session {sid}; tiles: {tiles}")
print("== 2. the screen is locked, and a window is moved while nothing can be drawn")
kde.sh(f"loginctl lock-session {sid}")
checks.check(
"komorebi noticed the lock screen coming up",
kde.wait_for(lambda: "the screen was locked" in daemon_log(), timeout=30),
)
kde.sh(f"kdotool windowmove {kde.wid('sl-b')} 400 300")
checks.check(
"while it is locked komorebi has the same three windows: the lock screen is not one",
kde.managed(0) == 3,
)
checks.check(
"the other two did not move for it",
kde.rect(kde.wid("sl-a")) == tiles[0] and kde.rect(kde.wid("sl-c")) == tiles[2],
)
print("== 3. the screen is unlocked")
kde.sh(f"loginctl unlock-session {sid}")
checks.check(
"komorebi noticed the screen being unlocked",
kde.wait_for(lambda: "the screen was unlocked" in daemon_log(), timeout=30),
)
# the tile it has now, not the one it had: the shell's panel can take room from the windows after
# the windows were tiled, and the daemon puts every window in the room that is left
checks.check(
"the window that was moved is in its tile again",
kde.wait_for(lambda: near(kde.rect(kde.wid("sl-b")), tile_of("sl-b")), timeout=30),
)
checks.check("it still has three windows", kde.managed(0) == 3)
checks.check(
"its idea of the focus is KWin's",
kde.wait_for(
lambda: kde.active() != "" and kde.title(kde.active()) == kde.focused_title(0),
timeout=30,
),
)
checks.check(
"and it is still running",
kde.run(f"systemctl --user is-active --quiet {UNIT}")[0] == 0,
)
checks.finish()