diff --git a/README.md b/README.md index 764cafbd..e5602e81 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index bb4f5b87..359fa622 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -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 diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index e729f159..ba7559a2 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -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. diff --git a/docs/dev/PLATFORM.md b/docs/dev/PLATFORM.md index 6b09a193..795d8e1b 100644 --- a/docs/dev/PLATFORM.md +++ b/docs/dev/PLATFORM.md @@ -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 diff --git a/docs/index.md b/docs/index.md index dce76995..48d3b843 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/usage/sleeping-and-locking.md b/docs/usage/sleeping-and-locking.md new file mode 100644 index 00000000..0f46cea3 --- /dev/null +++ b/docs/usage/sleeping-and-locking.md @@ -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. diff --git a/docs/usage/subscribing-to-notifications.md b/docs/usage/subscribing-to-notifications.md index 3bb80675..63e6adcc 100644 --- a/docs/usage/subscribing-to-notifications.md +++ b/docs/usage/subscribing-to-notifications.md @@ -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. diff --git a/flake.nix b/flake.nix index 1fea2fec..6381f9cb 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/komorebi/src/kwin/bridge.js b/komorebi/src/kwin/bridge.js index 36214db9..be56812e 100644 --- a/komorebi/src/kwin/bridge.js +++ b/komorebi/src/kwin/bridge.js @@ -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(); diff --git a/komorebi/src/kwin/bridge.test.js b/komorebi/src/kwin/bridge.test.js index 195beb92..977957cb 100644 --- a/komorebi/src/kwin/bridge.test.js +++ b/komorebi/src/kwin/bridge.test.js @@ -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}" }), diff --git a/komorebi/src/kwin/mod.rs b/komorebi/src/kwin/mod.rs index 5716de11..9f6f33e9 100644 --- a/komorebi/src/kwin/mod.rs +++ b/komorebi/src/kwin/mod.rs @@ -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) -> eyre::Result<()> { })?; *shared.connection.lock() = Some(connection.clone()); + power::watch(connection.clone()).await; enable_per_output_desktops(&shared, &connection).await; load_script(&connection).await } diff --git a/komorebi/src/kwin/power.rs b/komorebi/src/kwin/power.rs new file mode 100644 index 00000000..2d1c467a --- /dev/null +++ b/komorebi/src/kwin/power.rs @@ -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) { + 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::() 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::() 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); +} diff --git a/komorebi/src/monitor_reconciliator.rs b/komorebi/src/monitor_reconciliator.rs index dd05f4d0..84024755 100644 --- a/komorebi/src/monitor_reconciliator.rs +++ b/komorebi/src/monitor_reconciliator.rs @@ -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, Receiver)> = @@ -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>) -> color_eyre::Result<()> { tracing::info!("listening"); let receiver = event_rx(); @@ -519,6 +547,13 @@ pub fn handle_notifications(wm: Arc>) -> 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(); diff --git a/komorebi/tests/power.rs b/komorebi/tests/power.rs new file mode 100644 index 00000000..4d8547e6 --- /dev/null +++ b/komorebi/tests/power.rs @@ -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 { + 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" + ); + }); +} diff --git a/komorebi/tests/wake.rs b/komorebi/tests/wake.rs new file mode 100644 index 00000000..1932cf7d --- /dev/null +++ b/komorebi/tests/wake.rs @@ -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, 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, 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 = 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 = [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 = [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" + ); +} diff --git a/nix/tests/vm/suites/lock-sleep/default.nix b/nix/tests/vm/suites/lock-sleep/default.nix new file mode 100644 index 00000000..91ff32cb --- /dev/null +++ b/nix/tests/vm/suites/lock-sleep/default.nix @@ -0,0 +1,5 @@ +{ + tier = "slow"; + kind = "session"; + timeoutMinutes = 15; +} diff --git a/nix/tests/vm/suites/lock-sleep/test.py b/nix/tests/vm/suites/lock-sleep/test.py new file mode 100644 index 00000000..3a1564db --- /dev/null +++ b/nix/tests/vm/suites/lock-sleep/test.py @@ -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()