From c8dc92a236e80996d4d5a01ec27d4658cebfbf3c Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 19:38:24 -0700 Subject: [PATCH] feat(wm): a monitor changes size When an output gets another resolution or scale, or a panel starts, grows or hides, the work area the layout fills is not the one komorebi tiled in, and nothing moved the windows until something else tiled them again. The bridge now sends the outputs, with their geometry and the work area of the desktop each shows, when KWin says they changed, when a dock window opens, closes or moves, and once a second, because KWin has no signal for the room a panel leaves. The table turns a monitor whose size or work area changed into a monitor notification. It is not a window event, since it is about no window: it goes to the monitor reconciliator, a thread that takes the window manager's lock as the threads of events and messages do, reads the size and work area of every monitor again and tiles the workspace in front of the focused monitor. Another monitor's windows keep their tiles until a retile. Every subscriber is notified of it, whether or not the state changed. vm-hotplug starts with the scale of one of two outputs changing under a running daemon. The VM library gets kde.outputs, which lists, disables, enables and scales outputs, for the commits that unplug and plug them. --- README.md | 19 ++- docs/dev/ARCHITECTURE.md | 80 +++++++++--- docs/dev/DESIGN.md | 13 ++ docs/dev/PLATFORM.md | 18 ++- docs/dev/vm-tests.md | 7 +- docs/index.md | 2 + docs/usage/changing-monitors.md | 29 +++++ docs/usage/subscribing-to-notifications.md | 23 ++-- komorebi-client/src/lib.rs | 1 + komorebi-client/tests/subscribe.rs | 29 +++++ komorebi/src/backend/fake.rs | 9 ++ komorebi/src/desktop.rs | 64 +++++++++ komorebi/src/kwin/bridge.js | 49 ++++++- komorebi/src/kwin/bridge.test.js | 95 +++++++++++++- komorebi/src/kwin/fixtures/events.json | 10 ++ komorebi/src/kwin/kwin-api.d.ts | 5 + komorebi/src/kwin/mod.rs | 9 ++ komorebi/src/kwin/state.rs | 75 ++++++++++- komorebi/src/lib.rs | 38 ++++-- komorebi/src/main.rs | 8 +- komorebi/src/monitor_reconciliator.rs | 144 +++++++++++++++++++++ komorebi/tests/monitor_resize.rs | 142 ++++++++++++++++++++ nix/tests/vm/lib/komorebi_vm/__init__.py | 17 ++- nix/tests/vm/lib/komorebi_vm/kde.py | 69 +++++++++- nix/tests/vm/lib/tests/test_kde.py | 61 ++++++++- nix/tests/vm/suites/hotplug/default.nix | 5 + nix/tests/vm/suites/hotplug/test.py | 98 ++++++++++++++ 27 files changed, 1044 insertions(+), 75 deletions(-) create mode 100644 docs/usage/changing-monitors.md create mode 100644 komorebi/src/monitor_reconciliator.rs create mode 100644 komorebi/tests/monitor_resize.rs create mode 100644 nix/tests/vm/suites/hotplug/default.nix create mode 100644 nix/tests/vm/suites/hotplug/test.py diff --git a/README.md b/README.md index 797e46f1..36a085ce 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,11 @@ without a checkout of the toolchain. `Ring` that holds monitors, workspaces and containers, and the lists in which some elements are locked in place. It answers the messages on the daemon's socket (`core::SocketMessage`, `process_command`), shows its state (`state`), - sends its subscribers a notification of each event and message it handles - (`notify_subscribers`), and is configured by a `komorebi.json` that it reads - and reads again when it changes (`static_config`). + keeps the monitors in step with the compositor's outputs as they change size + (`monitor_reconciliator`), 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. @@ -163,6 +165,11 @@ When something else switches a monitor to another desktop (a pager, a shortcut, an application raising a window of another desktop), or puts a window on another desktop, the window manager follows. +When a monitor changes size (a resolution, a scale, a panel that takes more room), +the bridge reports the outputs again and the monitor reconciliator, a thread of +the daemon, reads the size and the work area of every monitor and tiles the +workspace in front of the focused monitor in the area it now has. + 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 @@ -249,9 +256,9 @@ module's `config` option is typed from that schema (`nix/komorebi-options.nix`, `just nixgen`), so a configuration written in Nix is checked when it is evaluated. A program such as a status bar can subscribe to the daemon's notifications: it -listens on a socket of its own, and after each event and each message, the -daemon connects to it and writes the event or the message with the state of -the window manager. +listens on a socket of its own, and after each event, each message and each +change of the monitors, the daemon connects to it and writes it with the state +of the window manager. On NixOS, the module [`nix/nixos-module.nix`](nix/nixos-module.nix) runs the daemon as a systemd user unit that the Plasma session starts, and systemd diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 21234acc..8815945d 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -8,11 +8,11 @@ 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 a message on the daemon's socket is answered, how subscribers -are notified of both, 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, 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 @@ -68,8 +68,11 @@ has its place here. settings, as the daemon answers a `State` message with them ([`State`]). - **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. -- **Notification**: what a subscriber is sent after each event and each message the window - manager handles: the event or the message, and the state after it ([`Notification`]). +- **Monitor notification**: what the KWin backend sends the monitor reconciliator when the + outputs change ([`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`]). - **Look**: how the compositor is asked to dress the windows, which it draws ([`Look`]): a border, with its width, its corner radius and two colors, and the animation of windows going to their places, with its duration and its easing curve. @@ -312,6 +315,26 @@ table treats as stale ([`Table::stale_park`]). It is never the parking desktop o The snapshot sends the windows on it to the first workspace's desktop, and half a second later the backend removes it (`sweep_stale_park`). +## Monitors that change + +The outputs KWin has change under a running daemon: an output gets another resolution or +scale, or a panel changes the room the windows have on it. KWin has one signal for the outputs +(`workspace.screensChanged`) and none for the room a panel leaves, so the bridge sends every +output, with its geometry and work area, as a `screens` event when the signal comes, when a +dock window opens, closes or moves, and once a second, each time the outputs are not what it +last sent. The table compares them with the monitors it had ([`Table::handle`]), and a monitor +whose size or work area changed is a [`MonitorNotification::Resize`]. It is not a window +event, since it is about no window, and it goes to the monitor reconciliator +([`monitor_reconciliator`]), not to the window manager's channel of events. + +The reconciliator is a thread with a channel of its own ([`listen_for_notifications`]). It takes +one notification at a time with the window manager locked, as the thread of events and the +thread of messages do, so the three never change the window manager at once. For a `Resize` it +reads the size and the work area of every monitor from the backend again +([`update_monitor_work_areas`]), tiles the workspace in front of the focused monitor in the area +it has now, and notifies the subscribers ([`NotificationEvent::Monitor`]). Another monitor's +windows keep their tiles until something tiles them again, such as `Retile`. + ## The command socket The daemon takes messages on a Unix socket, `komorebi.sock` in [`DATA_DIR`] @@ -354,9 +377,10 @@ or `AddSubscriberSocketWithOptions` with its name and [`SubscribeOptions`] [`SUBSCRIPTION_SOCKET_OPTIONS`]. `RemoveSubscriberSocket` takes it out again. The subscribers are kept in memory only, and a daemon that restarts has none. -At the end of [`WindowManager::process_event`] and of [`WindowManager::process_command`], -the window manager calls [`notify_subscribers`] with a [`Notification`]: the event or the -message ([`NotificationEvent`]), and the [`State`] after it. A [`State`] made before the event +At the end of [`WindowManager::process_event`] and of [`WindowManager::process_command`], and +after the monitor reconciliator has handled a notification, the window manager calls +[`notify_subscribers`] with a [`Notification`]: the event, the message or the change of the +monitors ([`NotificationEvent`]), and the [`State`] after it. A [`State`] made before the event or the message is compared with the window manager after it ([`State::has_been_modified`]); the monitors are compared down to the windows, and a window by its id only. An event about a window komorebi does not manage, and a message that fails, return before this and are not @@ -366,13 +390,14 @@ notified. its socket, writes the JSON and closes the connection, all on the thread that handled the event or the message, with the window manager still locked. A subscriber with `filter_state_changes` is skipped when the state did not change, unless the notification is -about a window being shown, a subscriber being added, a theme being set (the colors of the -borders are not part of the state) or the configuration being reloaded. A subscriber that -cannot be connected to is removed, with its socket file, after the loop. Writing to a -subscriber that was connected to has no timeout, and a failed write returns an error from the -call that handled the event or the message. A subscriber that stops reading therefore holds -the window manager, and with it the thread that handles events: the backend's events wait in -the channel of [`window_manager_event_listener`], and past 20 the backend drops them. +about a window being shown, a subscriber being added, a change of the monitors, a theme being +set (the colors of the borders are not part of the state) or the configuration being reloaded. +A subscriber that cannot be connected to is removed, with its socket file, after the loop. +Writing to a subscriber that was connected to has no timeout, and a failed write returns an +error from the call that handled the event or the message. A subscriber that stops reading +therefore holds the window manager, and with it the thread that handles events: the backend's +events wait in the channel of [`window_manager_event_listener`], and past 20 the backend drops +them. When the daemon stops ([`WindowManager::stop`], or `SIGINT` and `SIGTERM` in `main.rs`), it connects to each subscriber and shuts the connection down without writing anything. @@ -1046,13 +1071,16 @@ module's unit. that is not in front, focus another monitor, and move containers and workspaces between monitors). - [`desktop`](../../komorebi/src/desktop.rs): loads the monitors and the open windows from - the backend. + the backend, and reads the sizes of the monitors again. - [`window_manager_event`](../../komorebi/src/window_manager_event.rs) and [`process_event`](../../komorebi/src/process_event.rs): the events, and what the window manager does with each. - [`window_manager_event_listener`](../../komorebi/src/window_manager_event_listener.rs): the channel that carries events from the backend to the window manager, and [`listen_for_events`], the thread that takes them off it. +- [`monitor_reconciliator`](../../komorebi/src/monitor_reconciliator.rs): the channel the KWin + backend sends [`MonitorNotification`]s on, and [`listen_for_notifications`], the thread that + takes them and has the window manager read the monitors again. - [`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::kwinrc`](../../komorebi/src/kwin/kwinrc.rs), the editor of the user's `kwinrc`, which @@ -1088,8 +1116,8 @@ module's unit. - [`main.rs`](../../komorebi/src/main.rs): the daemon. It connects to KWin, or installs the fake with `--fake-backend`, makes the window manager from `komorebi.json` when there is one, which binds the socket, loads the desktop, gives the monitors their configuration, asks for the - borders and tiles, starts the threads that listen on the socket and handle events, and waits to - be stopped. + borders and tiles, starts the threads that listen on the socket, handle events and take the + notifications of the monitors, and waits to be stopped. - [`nixos-module.nix`]: the NixOS module, which installs komorebi and runs the daemon as a systemd user unit of every Plasma session. @@ -1217,6 +1245,9 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | A monitor raised onto the parking desktop is sent back | framework path: the table sends the monitor back to the desktop it showed before | `following_a_hidden_window_to_the_parking_desktop_sends_that_output_back` | none | | A clean stop leaves no window hidden and no parking desktop | framework path: [`WindowManager::stop`] and the signal handler restore every hidden window, and the backend's `shutdown` removes the desktop after the restoring commands have gone out | `restoring_every_window_brings_back_the_hidden_ones_unless_it_is_told_to_ignore_that`, `vm-stacks` ("a clean stop puts every window back") | `SIGKILL`, and `StopIgnoreRestore`, which restores nothing | | A parking desktop a dead daemon left is swept by the next one, and its windows go home | startup check: the first snapshot marks it stale, sends its windows to the first workspace's desktop, and the backend removes it | `a_parking_desktop_left_by_a_crashed_run_is_swept`, `a_stale_parking_desktop_is_not_the_one_windows_are_hidden_on`, `vm-stacks` ("a crash leaves a parking desktop") | none | +| A monitor that changes size has the windows of the workspace in front tiled in the size it has now | framework path: the table reports a changed size or work area as a `Resize`, and the reconciliator reads the monitors again and tiles the focused monitor | `a_new_panel_is_a_resize_and_the_same_screens_again_are_nothing`, `a_change_of_resolution_is_tiled_again`, `vm-hotplug` ("tiled again inside the monitor as it is now") | `Retile` tiles every monitor | +| The outputs are sent when they are not what the bridge last sent, and the room a panel leaves is found within a second | convention in the bridge: it sends after `screensChanged`, after a dock window opens, closes or moves, and in its watchdog, which runs every second | `a change of the outputs is sent as the screens, once, and the panel is looked at again`, `a panel that opens, moves or closes has the area of the outputs looked at again`, `the outputs are looked at once a second, which finds a panel that starts after the bridge` | 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 | | The bridge holds no window manager logic | convention, with every decision about an event in the table, which the `kwin::state` unit tests pin | the unit tests in `komorebi/src/kwin/state.rs` | none | @@ -1441,6 +1472,9 @@ A window is shown, and ends up on a tile: corners dragged with a pointer in a Plasma session; [`nix/tests/vm/suites/live-resize`](../../nix/tests/vm/suites/live-resize/test.py), the neighbors read while the edge is held. +- [`komorebi/tests/monitor_resize.rs`](../../komorebi/tests/monitor_resize.rs): a monitor that + changes size, against the fake; [`nix/tests/vm/suites/hotplug`](../../nix/tests/vm/suites/hotplug/test.py), + the same against a real KWin with two outputs. - [`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, @@ -1670,6 +1704,12 @@ A window is shown, and ends up on a tile: [`WindowManager::cross_boundary_behaviour`]: ../../komorebi/src/window_manager.rs [`Monitor::focus_workspace`]: ../../komorebi/src/monitor.rs [`Monitor::move_container_to_workspace`]: ../../komorebi/src/monitor.rs +[`monitor_reconciliator`]: ../../komorebi/src/monitor_reconciliator.rs +[`MonitorNotification`]: ../../komorebi/src/monitor_reconciliator.rs +[`MonitorNotification::Resize`]: ../../komorebi/src/monitor_reconciliator.rs +[`listen_for_notifications`]: ../../komorebi/src/monitor_reconciliator.rs +[`update_monitor_work_areas`]: ../../komorebi/src/desktop.rs +[`NotificationEvent::Monitor`]: ../../komorebi/src/lib.rs [`bridge.js`]: ../../komorebi/src/kwin/bridge.js [`kwin-api.d.ts`]: ../../komorebi/src/kwin/kwin-api.d.ts [`bridge.test.js`]: ../../komorebi/src/kwin/bridge.test.js diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index b9c259ce..bf9329b8 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -587,3 +587,16 @@ newest, and a single-shot timer sends the last one of a burst, so the edge's fin lost. Each step says where the edge is now, so one waiting in the window manager's channel is enough: the window manager asks the window where its edges are when it gets to the step, and that is the newest. + +## Why does the bridge look at the outputs once a second? + +Because KWin has no signal for the room a panel leaves, only for the outputs themselves. A +panel that starts, grows, moves or hides changes the work area of an output and raises nothing +the script can connect to, so the bridge reads the work area of every output again when +`screensChanged` is raised and when a dock window opens, closes or moves, and once a second in +its watchdog. Two of the moments are not enough alone: komorebi usually starts before the panel +does, and a panel is laid out after the resolution it follows, so the area at the moment of the +signal is not the final one. The bridge keeps the outputs it last sent and sends them only when +they are not the same, so a second that changed nothing sends nothing. The cost is that a panel +that changes its room with no dock window event is found up to a second late, and that the +bridge does a little work every second. diff --git a/docs/dev/PLATFORM.md b/docs/dev/PLATFORM.md index 29951f3a..6893329c 100644 --- a/docs/dev/PLATFORM.md +++ b/docs/dev/PLATFORM.md @@ -123,9 +123,11 @@ It connects to the session bus as `org.komorebi.Bridge`, writes the bridge to `$XDG_RUNTIME_DIR/komorebi/bridge.js`, and asks KWin to load it under the name `komorebi-bridge`, replacing a script of that name left by an earlier run. The script reports the windows and outputs it finds, and from then on every window that opens, closes, takes the -focus, is minimized or brought back, moves or changes size, and the start and the end of every -drag of a window by the user. The daemon waits up to 10 seconds for the first report and does -not start without it. +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. 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 @@ -427,6 +429,16 @@ and KWin's default snap zone of 10 pixels, a dragged edge once ended 9 pixels pa drag VM tests set KWin's snap zones to 0. The VM test `vm-drag-resize` drags edges in a Plasma session with a pointer device that the session node makes with uinput. +## Outputs that change + +Measured by the VM test `vm-hotplug` at time of writing (2026-10-27), in a headless KWin 6.8.1 +with two outputs of 1920x1080: + +- `kscreen-doctor output..scale.1.5` gives an output the geometry 1280x720. That is what + the script reports and komorebi tiles in: every coordinate komorebi uses is KWin's logical + one. At that scale `kdotool` prints window frames in fractions of a pixel (a width of + 695.3333), which the VM library rounds. + ## 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/dev/vm-tests.md b/docs/dev/vm-tests.md index ac3367e0..869501bc 100644 --- a/docs/dev/vm-tests.md +++ b/docs/dev/vm-tests.md @@ -110,9 +110,14 @@ the run. - `Kde` is one KWin session. `Kde.headless(machine, name, size=, outputs=, scale=)` starts the units and waits for KWin to own its name on the private bus. `wid`, `rect`, `title` and `active` read windows through `kdotool`, - `outputs` counts the enabled outputs through `kscreen-doctor`, `spawn` starts + `outputs()` counts the enabled outputs through `kscreen-doctor`, `spawn` starts a client, and `wait_for(predicate, timeout)` polls, with the timeout multiplied by `scale`. +- `Kde.outputs` has the outputs: `names()` lists them from left to right, and + `disable(name)`, `enable(name)` and `scale(name, factor)` change them with + `kscreen-doctor`, which is how a suite changes the monitors under the daemon. + A disabled output keeps its name, so `names(enabled=False)` finds it to enable + again. - `Kde.session(machine, scale=)` attaches to the session node's own Plasma session once its desktop is up, and `shot(name)` returns a screenshot as a Pillow image, keeping `NAME.png` in the output directory. diff --git a/docs/index.md b/docs/index.md index 4186e420..3584fc0b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,6 +67,8 @@ see-through, [Focus follows mouse](usage/focus-follows-mouse.md) how it has KWin to the window under the pointer, [Themes](usage/themes.md) how a theme gives the borders their 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. [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/changing-monitors.md b/docs/usage/changing-monitors.md new file mode 100644 index 00000000..b4740920 --- /dev/null +++ b/docs/usage/changing-monitors.md @@ -0,0 +1,29 @@ +# Monitors that change + +komorebi follows the outputs KWin has. There is nothing to configure and no command to run: this +page says what komorebi does when a monitor changes. + +## A monitor changes size + +A monitor changes size when its resolution or its scale changes (in System Settings, under +Display & Monitor, or with `kscreen-doctor output..scale.1.5`), and when a panel changes +the room the windows have on it: a panel that starts, grows, moves or is hidden. komorebi reads +the size and the work area of every monitor again, and tiles the workspace in front on the +focused monitor in the area it now has. + +The windows of the other monitors keep their tiles until something tiles them again. +[`komorebic retile`](../cli/retile.md) does it for every monitor. + +KWin has no signal for the room a panel leaves, so komorebi looks at it when a panel opens, +closes or moves, and once a second. A panel that is laid out a moment after komorebi starts is +found within a second. + +A monitor that is unplugged or plugged in is not followed: komorebi keeps the monitors it +started with until it is started again. + +## Following it from a program + +A [subscriber](subscribing-to-notifications.md) is sent a notification each time komorebi has +read the monitors again, whether or not anything changed in the state. Its `event` is +`{"Resize":0}`, where the number is the id of the monitor that changed, or 0 when KWin does not +say which. diff --git a/docs/usage/subscribing-to-notifications.md b/docs/usage/subscribing-to-notifications.md index b87a0632..6c70be28 100644 --- a/docs/usage/subscribing-to-notifications.md +++ b/docs/usage/subscribing-to-notifications.md @@ -2,9 +2,9 @@ A program such as a status bar, a widget or a script can follow what komorebi does by subscribing to its notifications. After each event the window manager handles (a window -was shown, closed, focused, minimized or restored) and each message it takes on its socket, -the daemon sends every subscriber a notification: the event or the message, and the state -of the window manager after it. +was shown, closed, focused, minimized or restored), each message it takes on its socket and +each change of the monitors, the daemon sends every subscriber a notification: the event, the +message or the change, and the state of the window manager after it. ## Subscribing @@ -34,11 +34,13 @@ object. The first notification a subscriber receives is the one of its subscript A notification has two fields: -- `event`: the event or the message. An event is a window event, with the signal it came - from and the window, such as +- `event`: the event, the message or the change of the monitors. An event is a window event, + with the signal it came from and the window, such as `{"type":"Show","content":[{"type":"KWin","content":"WindowAdded"},{"id":...}]}`. A message is written the way a client writes it on the daemon's socket, such as - `{"type":"Retile"}`. + `{"type":"Retile"}`. A change of the monitors is the name of the change with its content, + such as `{"Resize":0}`: the id of the monitor that changed, or 0 when KWin does not say + which. [Changing monitors](changing-monitors.md) says 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. @@ -49,10 +51,11 @@ sent. A subscriber can ask to be sent a notification only when the event or the message changed the state, with the `filter_state_changes` option. It is still sent the notification of a -window that is shown, of every new subscription, so it receives the state as soon as it -subscribes, of a theme, which changes the colors of the borders and nothing in the state, -and of every reload of `komorebi.json`. `komorebic subscribe-socket` has no option for it; a -program writes the message on the daemon's socket itself: +window that is shown, of every change of the monitors, of every new subscription, so it +receives the state as soon as it subscribes, of a theme, which changes the colors of the +borders and nothing in the state, and of every reload of `komorebi.json`. +`komorebic subscribe-socket` has no option for it; a program writes the message +on the daemon's socket itself: ```sh printf '%s' '{"type":"AddSubscriberSocketWithOptions","content":["bar.sock",{"filter_state_changes":true}]}' | diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index caa9fea4..290733ba 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -79,6 +79,7 @@ pub use komorebi::core::animation::AnimationStyle; pub use komorebi::core::asc::ApplicationSpecificConfiguration; pub use komorebi::core::pathext::PathExt; pub use komorebi::core::pathext::replace_env_in_path; +pub use komorebi::monitor_reconciliator::MonitorNotification; pub use komorebi::state::State; pub use komorebi::static_config::StaticConfig; pub use komorebi::window::Window; diff --git a/komorebi-client/tests/subscribe.rs b/komorebi-client/tests/subscribe.rs index da9c687f..d47e27f4 100644 --- a/komorebi-client/tests/subscribe.rs +++ b/komorebi-client/tests/subscribe.rs @@ -10,6 +10,7 @@ use komorebi::SUBSCRIPTION_SOCKETS; use komorebi::backend::fake::FakeBackend; use komorebi::backend::set_backend; +use komorebi::monitor_reconciliator; use komorebi::process_command::listen_for_commands; use komorebi::window::Window; use komorebi::window_manager::WindowManager; @@ -18,6 +19,7 @@ use komorebi::window_manager_event::SystemNotification; use komorebi::window_manager_event::WindowManagerEvent; use komorebi::window_manager_event_listener; use komorebi_client::DATA_DIR; +use komorebi_client::MonitorNotification; use komorebi_client::Notification; use komorebi_client::NotificationEvent; use komorebi_client::SocketMessage; @@ -85,6 +87,7 @@ fn daemon() -> (parking_lot::MutexGuard<'static, ()>, &'static Daemon) { wm.init().unwrap(); let wm = Arc::new(Mutex::new(wm)); listen_for_commands(wm.clone()); + monitor_reconciliator::listen_for_notifications(wm.clone()).unwrap(); Daemon { fake, wm } }); @@ -283,6 +286,32 @@ fn a_subscriber_that_filters_state_changes_is_sent_a_theme() Ok(()) } +#[test] +fn a_change_of_the_monitors_is_sent_to_a_subscriber_that_filters_state_changes_too() +-> Result<(), Box> { + let (_turn, _daemon) = daemon(); + let options = SubscribeOptions { + filter_state_changes: true, + }; + let filtered = Subscriber::new( + "monitors.sock", + subscribe_with_options("monitors.sock", options)?, + ); + filtered.subscribed(); + + // the monitors are read again and are what they were: the state is the same + monitor_reconciliator::send_notification(MonitorNotification::Resize(0)); + + let received = filtered.until(|event| { + matches!( + event, + NotificationEvent::Monitor(MonitorNotification::Resize(0)) + ) + }); + assert_eq!(received.len(), 1, "{received:?}"); + Ok(()) +} + #[test] fn a_subscriber_that_is_gone_is_dropped_and_not_tried_again() -> Result<(), Box> { diff --git a/komorebi/src/backend/fake.rs b/komorebi/src/backend/fake.rs index 7a7d30e5..1ceee1b7 100644 --- a/komorebi/src/backend/fake.rs +++ b/komorebi/src/backend/fake.rs @@ -233,6 +233,15 @@ impl FakeBackend { fake } + /// Gives a monitor another size and work area, as a change of resolution or scale, or a panel + /// that moves, does. Nothing is done when there is no monitor with this id. + pub fn resize_monitor(&self, id: u32, size: Rect, work_area: Rect) { + if let Some(monitor) = self.state.lock().monitors.iter_mut().find(|m| m.id == id) { + monitor.size = size; + monitor.work_area = work_area; + } + } + /// Adds a monitor. Its serial number identity is its name. pub fn add_monitor(&self, id: u32, name: &str, size: Rect, work_area: Rect) { self.state.lock().monitors.push(MonitorInfo { diff --git a/komorebi/src/desktop.rs b/komorebi/src/desktop.rs index 45725a4b..f7a8b73e 100644 --- a/komorebi/src/desktop.rs +++ b/komorebi/src/desktop.rs @@ -214,6 +214,70 @@ pub fn load_monitor_information(wm: &mut WindowManager) -> eyre::Result<()> { Ok(()) } +/// Reads the size and work area of every monitor again, and tiles the focused workspace of the +/// focused monitor in the work area it has now. +/// +/// A monitor is matched with the backend's by its id: a monitor the backend does not report is left +/// as it is, and so is one the window manager does not have yet ([`load_monitor_information`] adds +/// it). The window that has the focus is given the focus again. +/// +/// # Errors +/// +/// - The backend cannot report its monitors +/// ([`Backend::monitors`](crate::backend::Backend::monitors)). +/// - There is no focused monitor or workspace, or the windows of the focused workspace cannot be +/// put in their tiles. +/// +/// # Example +/// +/// ```rust +/// use std::path::PathBuf; +/// use std::sync::Arc; +/// +/// use komorebi::backend::fake::FakeBackend; +/// use komorebi::backend::with_backend; +/// use komorebi::core::Rect; +/// use komorebi::desktop::update_monitor_work_areas; +/// use komorebi::window_manager::WindowManager; +/// +/// let fake = Arc::new(FakeBackend::with_one_monitor()); +/// let (_sender, events) = crossbeam_channel::bounded(1); +/// let socket = PathBuf::from(format!("/tmp/komorebi-areas-doc-{}.sock", std::process::id())); +/// let mut wm = WindowManager::new(events, Some(socket.clone()))?; +/// with_backend(fake.clone(), || wm.init())?; +/// assert_eq!(wm.monitors()[0].work_area_size.bottom, 1050); +/// +/// // a panel takes more of the monitor +/// let size = Rect { left: 0, top: 0, right: 1920, bottom: 1080 }; +/// fake.resize_monitor(1, size, Rect { bottom: 1000, ..size }); +/// with_backend(fake, || update_monitor_work_areas(&mut wm))?; +/// +/// assert_eq!(wm.monitors()[0].work_area_size.bottom, 1000); +/// # std::fs::remove_file(socket)?; +/// # Ok::<(), color_eyre::eyre::Report>(()) +/// ``` +pub fn update_monitor_work_areas(wm: &mut WindowManager) -> eyre::Result<()> { + let all_devices = latest_monitor_information()?; + for device in all_devices { + for monitor in wm.monitors_mut() { + if monitor.id == device.id { + monitor.size = device.size; + monitor.work_area_size = device.work_area_size; + + tracing::info!( + "updated monitor size and work area for monitor {}", + monitor.serial_number_id + ); + } + } + } + + let mouse_follows_focus = wm.mouse_follows_focus; + wm.update_focused_workspace(mouse_follows_focus, true)?; + + Ok(()) +} + /// Puts the windows that are already open into containers on the workspace of the monitor they /// are on, so that starting komorebi tiles what is on screen. /// diff --git a/komorebi/src/kwin/bridge.js b/komorebi/src/kwin/bridge.js index abbce7f7..9ea22977 100644 --- a/komorebi/src/kwin/bridge.js +++ b/komorebi/src/kwin/bridge.js @@ -52,8 +52,9 @@ * What the bridge sends, as the one argument of `Event`. `emit` adds `seq` to each, which counts * them from 1. `snapshot` is everything on the desktop, sent once, when the script starts; an * `activated` with no `id` means no window has the focus; `caption` is a window's new title; - * `desktops` says which desktops a window is on now, `desktop_list` which desktops there are, and - * `current_desktops` which desktop each output shows, each sent when it is not what was last sent; + * `screens` says which outputs there are, with their geometry and work area, `desktops` which + * desktops a window is on now, `desktop_list` which desktops there are, and `current_desktops` + * which desktop each output shows, each sent when it is not what was last sent; * `move_resize_started` and `move_resize_finished` are the start and the end of a drag of a window * by the user, with its frame, and at the end where the pointer is and the frame the user last * asked for (`requested`, or `null` when the drag asked for none); `move_resize_step` is the @@ -69,6 +70,7 @@ * @typedef {{ type: "activated", id: string | null }} Activated * @typedef {{ type: "minimized", id: string, v: boolean }} Minimized * @typedef {{ type: "geometry", id: string, geo: Rect }} Geometry + * @typedef {{ type: "screens", screens: Screen[] }} Screens * @typedef {{ type: "caption", id: string, caption: string }} Caption * @typedef {{ type: "desktops", id: string, desktops: string[] }} Desktops * @typedef {{ type: "desktop_list", desktops: Desktop[] }} DesktopList @@ -82,9 +84,9 @@ * @typedef {{ type: "move_resize_step", id: string, geo: Rect }} MoveResizeStep * @typedef {{ type: "cursor", token: number, x: number, y: number }} CursorAnswer * @typedef {{ type: "failed", batch: number, cmds: Command[] }} Failed - * @typedef {Snapshot | Added | Removed | Activated | Minimized | Geometry | Caption | Desktops - * | DesktopList | CurrentDesktops | MoveResizeStarted | MoveResizeFinished | MoveResizeStep - * | CursorAnswer | Failed} BridgeEvent + * @typedef {Snapshot | Added | Removed | Activated | Minimized | Geometry | Screens | Caption + * | Desktops | DesktopList | CurrentDesktops | MoveResizeStarted | MoveResizeFinished + * | MoveResizeStep | CursorAnswer | Failed} BridgeEvent */ /** @@ -235,6 +237,33 @@ function screens() { return out; } +// KWin has no signal for the usable area of a screen changing (a panel starting, resizing or +// hiding), only for the screens themselves, so the area is looked at again when something that +// could move it happens (a dock window comes, goes or moves) and once a second regardless: komorebi +// usually starts before the panel does, and a panel is laid out after the resolution it follows. +/** @type {string} */ +var lastScreens = ""; + +function announceScreens() { + var now = JSON.stringify(screens()); + if (now !== lastScreens) { + lastScreens = now; + emit("screens", { screens: JSON.parse(now) }); + } +} + +var areaTimer = new QTimer(); +areaTimer.singleShot = true; +areaTimer.interval = 200; +areaTimer.timeout.connect(announceScreens); + +/** @param {KWinWindow} w */ +function areaMayHaveChanged(w) { + if (w.dock) { + areaTimer.start(); + } +} + /** @returns {Point} */ function cursor() { var p = workspace.cursorPos; @@ -406,6 +435,7 @@ function track(w, announce) { w.frameGeometryChanged.connect(function (old) { emit("geometry", { id: id, geo: rect(w.frameGeometry) }); settle(w, id); + areaMayHaveChanged(w); }); w.minimizedChanged.connect(function () { emit("minimized", { id: id, v: !!w.minimized }); @@ -441,8 +471,10 @@ function track(w, announce) { workspace.windowAdded.connect(function (w) { track(w, true); + areaMayHaveChanged(w); }); workspace.windowRemoved.connect(function (w) { + areaMayHaveChanged(w); var id = wid(w); delete known[id]; delete want[id]; @@ -464,6 +496,11 @@ function announceActive(w) { } workspace.windowActivated.connect(announceActive); +workspace.screensChanged.connect(function () { + announceScreens(); + // the panel follows a new resolution a moment later + areaTimer.start(); +}); workspace.currentDesktopChanged.connect(function () { announceDesktops(); }); @@ -477,6 +514,7 @@ for (var i = 0; i < existing.length; i++) { } lastActive = workspace.activeWindow ? wid(workspace.activeWindow) : null; +lastScreens = JSON.stringify(screens()); lastCurrent = JSON.stringify(currentDesktops()); lastDesktopList = JSON.stringify(desktops()); @@ -611,6 +649,7 @@ watchdog.timeout.connect(function () { if (Date.now() - lastIssue > CFG.hold_ms + 3000) { pull(); } + announceScreens(); announceDesktops(); }); watchdog.start(); diff --git a/komorebi/src/kwin/bridge.test.js b/komorebi/src/kwin/bridge.test.js index 80c0223f..9ef08cdc 100644 --- a/komorebi/src/kwin/bridge.test.js +++ b/komorebi/src/kwin/bridge.test.js @@ -81,6 +81,7 @@ function window({ skipPager: false, skipSwitcher: false, opacity: 1, + dock: false, fullScreen: false, maximizeMode: 0, minSize: { width: 0, height: 0 }, @@ -187,6 +188,7 @@ function kwin({ windowAdded: new Signal(), windowRemoved: new Signal(), windowActivated: new Signal(), + screensChanged: new Signal(), currentDesktopChanged: new Signal(), desktopsChanged: new Signal(), }; @@ -283,9 +285,11 @@ function kwin({ desktops.push(desktop); workspace.desktopsChanged.emit(); }, - // the timer that calls `Poll` again, and the one that sends the last step of a resize + // the timer that calls `Poll` again, the one that sends the last step of a resize, and the + // one that waits for a panel to settle watchdog: () => timers.find((t) => t.interval === 1000), - stepTimer: () => timers.find((t) => t.singleShot), + stepTimer: () => timers.find((t) => t.interval === 16), + areaTimer: () => timers.find((t) => t.interval === 200), }; return k; } @@ -303,7 +307,13 @@ test("what the bridge sends for a session is what the daemon's tests read", () = minSize: { width: 300, height: 200 }, desktops: [{ id: "desktop-1" }], }); - const k = kwin({ windows: [editor], active: editor, cursor: { x: 480.6, y: 525.2 } }); + const screen = { ...DP1 }; + const k = kwin({ + windows: [editor], + screens: [screen], + active: editor, + cursor: { x: 480.6, y: 525.2 }, + }); const terminal = window({ id: "{7f3d9e21-0000-4000-8000-00000000000b}", @@ -380,6 +390,10 @@ test("what the bridge sends for a session is what the daemon's tests read", () = editor.resize = false; editor.interactiveMoveResizeFinished.emit(); + // and a panel takes room from the output + screen.placementArea = geometry(0, 0, 1920, 1000); + k.workspace.screensChanged.emit(); + assert.deepStrictEqual(k.events(), events); assert.deepStrictEqual(editor.frameGeometry, geometry(40, 30, 1040, 1050)); assert.equal(k.workspace.activeWindow, editor); @@ -393,12 +407,85 @@ test("what the bridge sends for a session is what the daemon's tests read", () = assert.deepStrictEqual(k.workspace.windowList(), [editor]); }); +test("a change of the outputs is sent as the screens, once, and the panel is looked at again", () => { + const screen = { ...DP1 }; + const k = kwin({ screens: [screen] }); + + screen.geometry = geometry(0, 0, 1280, 720); + screen.placementArea = geometry(0, 0, 1280, 700); + k.workspace.screensChanged.emit(); + k.workspace.screensChanged.emit(); + + assert.deepStrictEqual(k.events().slice(1), [ + { + type: "screens", + screens: [ + { + name: "DP-1", + manufacturer: "ACME", + model: "M1", + serial: "S1", + geo: { x: 0, y: 0, w: 1280, h: 720 }, + scale: 1, + area: { x: 0, y: 0, w: 1280, h: 700 }, + }, + ], + seq: 2, + }, + ]); + assert.ok(k.areaTimer().running, "the panel follows a new resolution a moment later"); +}); + +test("a panel that opens, moves or closes has the area of the outputs looked at again", () => { + const screen = { ...DP1 }; + const k = kwin({ screens: [screen] }); + const timer = k.areaTimer(); + assert.ok(timer.singleShot, "a burst of changes is one look"); + + const app = window({ id: "{app}", frameGeometry: geometry(0, 0, 800, 600) }); + k.open(app, { focus: false }); + app.frameGeometry = geometry(10, 10, 800, 600); + assert.ok(!timer.running, "a window that is not a panel is no reason to look"); + + const panel = window({ id: "{panel}", dock: true, frameGeometry: geometry(0, 1050, 1920, 30) }); + k.open(panel, { focus: false }); + assert.ok(timer.running, "the panel opened"); + + screen.placementArea = geometry(0, 0, 1920, 1000); + timer.fire(); + assert.deepStrictEqual(k.events().at(-1).screens[0].area, { x: 0, y: 0, w: 1920, h: 1000 }); + + panel.frameGeometry = geometry(0, 1000, 1920, 80); + assert.ok(timer.running, "the panel moved"); + + timer.fire(); + k.close(panel); + assert.ok(timer.running, "the panel closed"); +}); + +test("the outputs are looked at once a second, which finds a panel that starts after the bridge", () => { + const screen = { ...DP1 }; + const k = kwin({ screens: [screen] }); + + k.watchdog().timeout.emit(); + assert.equal(k.events().length, 1, "nothing changed"); + + screen.placementArea = geometry(0, 0, 1920, 1000); + k.watchdog().timeout.emit(); + k.watchdog().timeout.emit(); + + assert.deepStrictEqual( + k.events().slice(1).map((e) => [e.type, e.screens[0].area.h]), + [["screens", 1000]], + ); +}); + test("the desktop each output shows is sent when it changes, and once", () => { const k = kwin(); const [first, second] = k.workspace.desktops; k.workspace.setCurrentDesktopForScreen(second, DP1); - k.timers[0].timeout.emit(); + k.watchdog().timeout.emit(); k.workspace.setCurrentDesktopForScreen(first, DP1); assert.deepStrictEqual(k.events().slice(1), [ diff --git a/komorebi/src/kwin/fixtures/events.json b/komorebi/src/kwin/fixtures/events.json index ea52812f..0b70b78e 100644 --- a/komorebi/src/kwin/fixtures/events.json +++ b/komorebi/src/kwin/fixtures/events.json @@ -110,5 +110,15 @@ "type": "move_resize_finished", "seq": 27, "id": "{0e5a1c3b-0000-4000-8000-00000000000a}", "geo": {"x": 40, "y": 30, "w": 1040, "h": 1050}, "requested": {"x": 40, "y": 30, "w": 1100, "h": 1050}, "cursor": {"x": 1500, "y": 600} + }, + { + "type": "screens", "seq": 28, + "screens": [ + { + "name": "DP-1", "manufacturer": "ACME", "model": "M1", "serial": "S1", + "geo": {"x": 0, "y": 0, "w": 1920, "h": 1080}, "scale": 1, + "area": {"x": 0, "y": 0, "w": 1920, "h": 1000} + } + ] } ] diff --git a/komorebi/src/kwin/kwin-api.d.ts b/komorebi/src/kwin/kwin-api.d.ts index 6f535d47..b41dd586 100644 --- a/komorebi/src/kwin/kwin-api.d.ts +++ b/komorebi/src/kwin/kwin-api.d.ts @@ -73,6 +73,8 @@ interface KWinWindow { readonly skipTaskbar: boolean; readonly skipPager: boolean; readonly skipSwitcher: boolean; + /** A panel: a window the compositor keeps other windows out of the way of. */ + readonly dock: boolean; minimized: boolean; /** How opaque the window is drawn, 0 to 1: 1 is not see-through at all. */ opacity: number; @@ -128,6 +130,9 @@ interface Workspace { readonly windowRemoved: Signal<[window: KWinWindow]>; /** `null` when no window has the focus. */ readonly windowActivated: Signal<[window: KWinWindow | null]>; + /** An output was added or removed, or has another geometry or scale. It does not say which, + * and the bridge reads every output. */ + readonly screensChanged: Signal; /** Some output shows another desktop. It does not say which, and the bridge does not need * what KWin passes with it: it reads the desktop of every output. */ readonly currentDesktopChanged: Signal; diff --git a/komorebi/src/kwin/mod.rs b/komorebi/src/kwin/mod.rs index e43b4559..5716de11 100644 --- a/komorebi/src/kwin/mod.rs +++ b/komorebi/src/kwin/mod.rs @@ -34,6 +34,11 @@ //! owner again, the daemon loads the script into the new KWin, which reports its desktop as it //! does at start. //! +//! The script also reports the outputs when KWin's list of them changes, when a panel (a dock +//! window) opens, closes or moves, and once a second, because KWin has no signal for the room a +//! panel leaves. A change of an output's size or of its work area is sent to +//! [`monitor_reconciliator`], which reads the monitors again. +//! //! 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 @@ -79,6 +84,7 @@ use crate::backend::Look; use crate::backend::MonitorInfo; use crate::backend::WindowInfo; use crate::core::Rect; +use crate::monitor_reconciliator; use crate::window_manager_event_listener; use color_eyre::eyre; use color_eyre::eyre::OptionExt; @@ -174,6 +180,9 @@ impl Shared { for output in outputs { match output { Output::Event(event) => window_manager_event_listener::send_notification(event), + Output::Monitor(notification) => { + monitor_reconciliator::send_notification(notification) + } Output::Command(command) => self.push(command), Output::Cursor { token, x, y } => { if let Some(waiter) = self.cursor_waiters.lock().remove(&token) { diff --git a/komorebi/src/kwin/state.rs b/komorebi/src/kwin/state.rs index f0cce162..d3997be2 100644 --- a/komorebi/src/kwin/state.rs +++ b/komorebi/src/kwin/state.rs @@ -68,6 +68,7 @@ use crate::backend::MonitorInfo; use crate::backend::WindowInfo; use crate::core::Rect; +use crate::monitor_reconciliator::MonitorNotification; use crate::window::Window; use crate::window_manager_event::KWinNotification; use crate::window_manager_event::SystemNotification; @@ -122,6 +123,8 @@ struct Entry { pub enum Output { /// A window event for the window manager. Event(WindowManagerEvent), + /// A change of the monitors, for the monitor reconciliator. + Monitor(MonitorNotification), /// A command for the script, to be queued with the others. Command(Value), /// The answer to a cursor query. @@ -694,6 +697,9 @@ impl Table { /// desktop (KWin followed an activation of a hidden window there) is sent back to the /// desktop it showed before, with an [`Output::Command`]. /// - `desktop_list`: the virtual desktops, in order. It raises no event. + /// - `screens`: the outputs, with their geometry and work area, sent when they are not what + /// was last sent. A monitor whose size or work area changed is a + /// [`MonitorNotification::Resize`] ([`Output::Monitor`]). /// - `cursor`: the answer to a cursor query ([`Output::Cursor`]). /// - `failed`: commands the script could not carry out. They are logged. /// @@ -719,6 +725,7 @@ impl Table { Some("desktops") => self.on_desktops(event), Some("move_resize_started") => self.on_drag_started(event), Some("move_resize_finished") => self.on_drag_finished(event), + Some("screens") => self.on_screens(event), Some("current_desktops") => self.on_current_desktops(event), Some("desktop_list") => { self.desktops = desktop_list(&event["desktops"]); @@ -809,15 +816,35 @@ impl Table { self.cursor = Some((x as i32, y as i32)); } - self.set_monitors(&event["screens"]); + out.extend(self.set_monitors(&event["screens"], false)); out } - fn set_monitors(&mut self, screens: &Value) { - self.monitors = screens + /// Replaces the monitors with `screens`. When `announce` is set and they are not what the + /// table had, the monitor that changed is announced as a resize: the id of the first monitor + /// whose size or work area is not what it was, or 0 when there is no such monitor. + fn set_monitors(&mut self, screens: &Value, announce: bool) -> Vec { + let monitors: Vec = screens .as_array() .map(|a| a.iter().map(monitor).collect()) .unwrap_or_default(); + + let previous = std::mem::replace(&mut self.monitors, monitors); + if !announce || previous == self.monitors { + return vec![]; + } + + // same monitors, different geometry or work area + let changed = self + .monitors + .iter() + .find(|m| previous.iter().any(|p| p.id == m.id && p != *m)) + .map_or(0, |m| m.id); + vec![Output::Monitor(MonitorNotification::Resize(changed))] + } + + fn on_screens(&mut self, event: &Value) -> Vec { + self.set_monitors(&event["screens"], true) } fn on_added(&mut self, event: &Value) -> Vec { @@ -1274,6 +1301,7 @@ mod tests { format!("WorkspaceChange to {idx}") } Output::Event(e) => format!("{e} {}", e.window_id().unwrap_or(0)), + Output::Monitor(notification) => format!("Monitor {notification}"), Output::Command(command) => format!("Command {command}"), Output::Cursor { token, x, y } => format!("Cursor {token} at {x},{y}"), }) @@ -1345,6 +1373,8 @@ mod tests { "move_resize_step -> ResizeStep 1", "geometry -> ", "move_resize_finished -> ResizeEnd 1", + // a panel takes room from the monitor + "screens -> Monitor Resize", ] ); @@ -1377,7 +1407,7 @@ mod tests { left: 0, top: 0, right: 1920, - bottom: 1050 + bottom: 1000 } ); } @@ -2159,6 +2189,43 @@ mod tests { assert_eq!(t.window_workspace(1), Some(2)); } + #[test] + fn a_new_panel_is_a_resize_and_the_same_screens_again_are_nothing() { + let mut t = table(vec![]); + let now = Instant::now(); + let mut screens = snapshot(vec![])["screens"].clone(); + + // a work area change: the panel grew + screens[0]["area"]["h"] = json!(1000); + let out = t.handle( + &json!({ "type": "screens", "screens": screens.clone() }), + now, + ); + assert!( + matches!(&out[..], [Output::Monitor(MonitorNotification::Resize(id))] if *id == t.monitors()[0].id), + "{out:?}" + ); + assert_eq!(t.monitors()[0].work_area.bottom, 1000); + + // a resolution change + screens[0]["geo"]["w"] = json!(1280); + screens[0]["geo"]["h"] = json!(720); + let out = t.handle(&json!({ "type": "screens", "screens": screens }), now); + assert!(matches!( + &out[..], + [Output::Monitor(MonitorNotification::Resize(_))] + )); + assert_eq!(t.monitors()[0].size.right, 1280); + + // the same screens again change nothing + let same = json!({ "type": "screens", "screens": [{ + "name": "DP-1", "manufacturer": "ACME", "model": "M1", "serial": "S1", + "geo": { "x": 0, "y": 0, "w": 1280, "h": 720 }, + "area": { "x": 0, "y": 0, "w": 1920, "h": 1000 }, + }] }); + assert!(t.handle(&same, now).is_empty()); + } + #[test] fn a_cursor_query_answer_is_passed_on_and_remembered() { let mut t = table(vec![]); diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index ab9cae26..9427e57f 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -18,14 +18,17 @@ //! the focus see-through. A monitor or a workspace can also have a wallpaper //! ([`static_config::Wallpaper`]), which the backend has the Plasma shell show when the //! workspace comes into view ([`backend::Backend::set_wallpaper`]). +//! When the compositor reports that a monitor changed size, [`monitor_reconciliator`] reads the +//! monitors again and tiles the windows in the area each has now. //! //! The daemon also listens on a socket, `komorebi.sock` in [`struct@DATA_DIR`], for the messages //! in [`core::SocketMessage`]: [`process_command`] reads them and answers, with the window //! manager's [`state::State`] for one. //! //! A program can subscribe to the daemon's notifications with a socket of its own in -//! [`struct@DATA_DIR`]: after each event and each message the window manager handles, -//! [`notify_subscribers`] sends every subscriber a [`Notification`] of it, with the state. +//! [`struct@DATA_DIR`]: after each event, each message and each change of the monitors the window +//! manager handles, [`notify_subscribers`] sends every subscriber a [`Notification`] of it, with +//! the state. use crate::core::ApplicationIdentifier; use crate::core::DefaultLayout; @@ -36,6 +39,7 @@ use crate::core::config_generation::IdWithIdentifier; use crate::core::config_generation::MatchingRule; use crate::core::config_generation::MatchingStrategy; use crate::core::config_generation::WorkspaceMatchingRule; +use crate::monitor_reconciliator::MonitorNotification; use crate::state::State; use crate::window::AspectRatio; use crate::window::PredefinedAspectRatio; @@ -68,6 +72,7 @@ pub mod desktop; pub mod kwin; pub mod lockable_sequence; pub mod monitor; +pub mod monitor_reconciliator; pub mod process_command; pub mod process_event; pub mod state; @@ -204,10 +209,10 @@ pub static DEFAULT_RESIZE_DELTA: i32 = 50; /// say pub static DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = true; -/// The event or the message a [`Notification`] is about +/// The event, the message or the change of the monitors a [`Notification`] is about /// -/// It is written as the [`WindowManagerEvent`] or the [`SocketMessage`] it carries, each with its -/// `type`, with no tag around it. +/// A [`WindowManagerEvent`] or a [`SocketMessage`] is written with its `type`, and a +/// [`MonitorNotification`] as its variant, `{"Resize":0}`, with no tag around either. #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(untagged)] @@ -216,10 +221,12 @@ pub enum NotificationEvent { WindowManager(WindowManagerEvent), /// A message the daemon took on its socket Socket(SocketMessage), + /// A change of the monitors the compositor reported + Monitor(MonitorNotification), } -/// A notification that [`notify_subscribers`] sends each subscriber: an event or a message the -/// window manager handled, and its state after it +/// A notification that [`notify_subscribers`] sends each subscriber: an event, a message or a +/// change of the monitors the window manager handled, and its state after it /// /// # Example /// @@ -229,6 +236,7 @@ pub enum NotificationEvent { /// use komorebi::Notification; /// use komorebi::NotificationEvent; /// use komorebi::core::SocketMessage; +/// use komorebi::monitor_reconciliator::MonitorNotification; /// use komorebi::window_manager_event::WindowManagerEvent; /// /// let state = r#"{"monitors":{"elements":[],"focused":0},"monitor_usr_idx_map":{},"is_paused":false,"resize_delta":50,"new_window_behaviour":"Create","float_override":false,"cross_monitor_move_behaviour":"Swap","monocle_focus_behaviour":"NoOp","unmanaged_window_operation_behaviour":"Op","work_area_offset":null,"mouse_follows_focus":true}"#; @@ -250,12 +258,19 @@ pub enum NotificationEvent { /// )); /// assert!(notification.state.mouse_follows_focus); /// +/// let resized = format!(r#"{{"event":{{"Resize":3}},"state":{state}}}"#); +/// let notification: Notification = serde_json::from_str(&resized)?; +/// assert!(matches!( +/// notification.event, +/// NotificationEvent::Monitor(MonitorNotification::Resize(3)) +/// )); +/// /// # Ok::<(), Box>(()) /// ``` #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Notification { - /// The event or the message the window manager handled + /// The event, the message or the change of the monitors the window manager handled pub event: NotificationEvent, /// The window manager's state after it pub state: State, @@ -267,8 +282,8 @@ pub struct Notification { /// Each subscriber is sent the notification as JSON on a new connection to its socket, which is /// closed after it. A subscriber with `filter_state_changes` in its [`SubscribeOptions`] is sent it /// only if `state_has_been_modified`, or if the notification is about a window being shown, a -/// subscriber being added, a theme being set (the colors of the borders are not part of the -/// [`State`]) or the configuration being reloaded. +/// subscriber being added, a change of the monitors, a theme being set (the colors of the borders +/// are not part of the [`State`]) or the configuration being reloaded. /// /// A subscriber that cannot be connected to (its program has exited, or its socket was removed) /// is removed, with its socket file, and is not tried again: it has to subscribe again. The @@ -326,7 +341,8 @@ pub fn notify_subscribers( ) -> eyre::Result<()> { let is_override_event = matches!( notification.event, - NotificationEvent::Socket(SocketMessage::AddSubscriberSocket(_)) + NotificationEvent::Monitor(_) + | NotificationEvent::Socket(SocketMessage::AddSubscriberSocket(_)) | NotificationEvent::Socket(SocketMessage::AddSubscriberSocketWithOptions(_, _)) | NotificationEvent::Socket(SocketMessage::Theme(_)) | NotificationEvent::Socket(SocketMessage::ReloadStaticConfiguration(_)) diff --git a/komorebi/src/main.rs b/komorebi/src/main.rs index 1a2e7fec..1bb5af8f 100644 --- a/komorebi/src/main.rs +++ b/komorebi/src/main.rs @@ -12,8 +12,9 @@ //! it changes. Without a file it runs on its defaults. It loads the monitors and the windows //! that are open, gives each monitor the configuration the file has for it, asks for the borders //! it draws ([`komorebi::border_manager`]), tiles the windows, listens for messages on -//! `komorebi.sock` in [`struct@DATA_DIR`], handles the events the backend reports on a thread of -//! their own, and runs until it is stopped, with `SIGINT`, `SIGTERM` or a `Stop` message. +//! `komorebi.sock` in [`struct@DATA_DIR`], handles the events the backend reports and the changes +//! of the monitors on threads of their own, and runs until it is stopped, with `SIGINT`, +//! `SIGTERM` or a `Stop` message. //! //! It logs to standard output and to a file a day in [`struct@LOG_DIR`], which `komorebic log` //! follows. @@ -33,6 +34,7 @@ use komorebi::backend; use komorebi::backend::fake::FakeBackend; use komorebi::border_manager; use komorebi::core::pathext::replace_env_in_path; +use komorebi::monitor_reconciliator; use komorebi::process_command::listen_for_commands; use komorebi::process_event::listen_for_events; use komorebi::static_config::StaticConfig; @@ -242,6 +244,8 @@ fn main() -> eyre::Result<()> { komorebi::transparency_manager::apply(&mut wm); } + monitor_reconciliator::listen_for_notifications(wm.clone())?; + listen_for_commands(wm.clone()); listen_for_events(wm.clone()); diff --git a/komorebi/src/monitor_reconciliator.rs b/komorebi/src/monitor_reconciliator.rs new file mode 100644 index 00000000..fe714baf --- /dev/null +++ b/komorebi/src/monitor_reconciliator.rs @@ -0,0 +1,144 @@ +//! Keeping komorebi's monitors in step with the compositor's outputs, as they change size. +//! +//! The KWin backend sends a [`MonitorNotification`] when KWin's list of outputs changes: an output +//! gets another resolution or scale, or a panel changes the room the windows have on it. The +//! thread [`listen_for_notifications`] starts takes them one at a time. For a +//! [`MonitorNotification::Resize`] the size and the work area of every monitor are read again +//! ([`desktop::update_monitor_work_areas`]), and the focused workspace of the focused monitor is +//! tiled in the area it has now. +//! +//! Every subscriber is sent a notification after each one is handled +//! ([`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` against the fake backend, and by the `vm-hotplug` VM +//! suite. + +use crate::Notification; +use crate::NotificationEvent; +use crate::border_manager; +use crate::desktop; +use crate::notify_subscribers; +use crate::state::State; +use crate::window_manager::WindowManager; +use crossbeam_channel::Receiver; +use crossbeam_channel::Sender; +use parking_lot::Mutex; +use serde::Deserialize; +use serde::Serialize; +use std::sync::Arc; +use std::sync::OnceLock; +use strum::Display; + +/// A change of the monitors, sent to the monitor reconciliator with [`send_notification`]. +/// +/// The number is a KWin monitor id, which the reconciliator does not read: it reads every monitor +/// again. +#[derive(Debug, Copy, Clone, Serialize, Deserialize, Display)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum MonitorNotification { + /// A monitor's size or work area changed (a panel moved, a resolution or scale changed): the + /// id of the monitor that changed, or 0 when the KWin backend cannot tell which + Resize(u32), +} + +static CHANNEL: OnceLock<(Sender, Receiver)> = + OnceLock::new(); + +/// The channel [`send_notification`] sends on and the thread of [`listen_for_notifications`] +/// receives from. It holds 20 notifications. +pub fn channel() -> &'static (Sender, Receiver) { + CHANNEL.get_or_init(|| crossbeam_channel::bounded(20)) +} + +fn event_tx() -> Sender { + channel().0.clone() +} + +fn event_rx() -> Receiver { + channel().1.clone() +} + +/// Sends `notification` to the monitor reconciliator, without waiting. A notification is dropped, +/// with a warning in the log, when the channel is full. +pub fn send_notification(notification: MonitorNotification) { + if event_tx().try_send(notification).is_err() { + tracing::warn!("channel is full; dropping notification") + } +} + +/// Starts the thread that takes the notifications, for as long as komorebi runs, and returns at +/// once. +/// +/// [`handle_notifications`] is called again when it returns, and the reason is logged. +/// +/// # Errors +/// +/// None: the result is always `Ok`. +pub fn listen_for_notifications(wm: Arc>) -> color_eyre::Result<()> { + std::thread::spawn(move || { + loop { + match handle_notifications(wm.clone()) { + Ok(()) => { + tracing::warn!("restarting finished thread"); + } + Err(error) => { + if cfg!(debug_assertions) { + tracing::error!("restarting failed thread: {:?}", error) + } else { + tracing::error!("restarting failed thread: {}", error) + } + } + } + } + }); + + Ok(()) +} + +/// Takes the notifications one at a time, for as long as komorebi runs. +/// +/// Each is dealt with as the [module docs](self) say. The subscribers are then notified, and the +/// backend is asked for the look again. +/// +/// # Errors +/// +/// Returns an error if the subscribers cannot be notified ([`notify_subscribers`]); +/// [`listen_for_notifications`] then calls it again. A monitor that cannot be read again is +/// logged. +pub fn handle_notifications(wm: Arc>) -> color_eyre::Result<()> { + tracing::info!("listening"); + let receiver = event_rx(); + + for notification in receiver { + let initial_state = { + let wm = wm.lock(); + State::from(wm.as_ref()) + }; + + match notification { + MonitorNotification::Resize(_display_id) => { + tracing::debug!("handling resize notification"); + let mut wm = wm.lock(); + if let Err(error) = desktop::update_monitor_work_areas(&mut wm) { + tracing::error!("failed to update monitor work areas: {error}"); + } + } + } + + { + let wm = wm.lock(); + notify_subscribers( + Notification { + event: NotificationEvent::Monitor(notification), + state: wm.as_ref().into(), + }, + initial_state.has_been_modified(&wm), + )?; + } + + border_manager::send_notification(); + } + + Ok(()) +} diff --git a/komorebi/tests/monitor_resize.rs b/komorebi/tests/monitor_resize.rs new file mode 100644 index 00000000..a9e3053a --- /dev/null +++ b/komorebi/tests/monitor_resize.rs @@ -0,0 +1,142 @@ +//! A monitor that changes size (a resolution, a scale, a panel that moves), against an in-memory +//! desktop: its windows are tiled again in the work area it has now. +//! +//! Window ids are in the 50_000s; the monitor cache is process-wide, so each test names its +//! monitors differently. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::Rect; +use komorebi::desktop; +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 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 a_change_of_resolution_is_tiled_again() { + let (fake, mut wm) = desktop_of(&["Test-I"]); + for id in [50_401, 50_402] { + open_on(&fake, &mut wm, id, 0); + } + + let smaller = Rect { + left: 0, + top: 0, + right: 1280, + bottom: 720, + }; + fake.resize_monitor( + 50, + smaller, + Rect { + bottom: 700, + ..smaller + }, + ); + with_backend(fake.clone(), || desktop::update_monitor_work_areas(&mut wm)).unwrap(); + + assert_tiled_in( + &fake, + &[50_401, 50_402], + &Rect { + bottom: 700, + ..smaller + }, + ); +} diff --git a/nix/tests/vm/lib/komorebi_vm/__init__.py b/nix/tests/vm/lib/komorebi_vm/__init__.py index b8e619a5..8365311d 100644 --- a/nix/tests/vm/lib/komorebi_vm/__init__.py +++ b/nix/tests/vm/lib/komorebi_vm/__init__.py @@ -6,6 +6,8 @@ A suite imports what it needs from here: * [`Kde`](komorebi_vm.kde.Kde) is a KWin session inside a test machine, with the tools to look at it (`kdotool`, `busctl`, `kscreen-doctor`, screenshots) and the komorebi daemon that runs in it. +* [`Outputs`](komorebi_vm.kde.Outputs) are the session's outputs, which a suite + disables, enables and scales to change the monitors under the daemon. * [`daemon`](komorebi_vm.daemon) reads what the daemon logs about the events it handles and the frames it asks for. * [`pixels`](komorebi_vm.pixels) reads a screenshot: the color of a pixel, and @@ -22,8 +24,19 @@ against a fake machine (the `vm-lib` check does). from komorebi_vm import daemon, latency, pixels, uinput from komorebi_vm.checks import Checks -from komorebi_vm.kde import Kde, Rect +from komorebi_vm.kde import Kde, Outputs, Rect from komorebi_vm.machine import Machine from komorebi_vm.pointer import Pointer -__all__ = ["Checks", "Kde", "Machine", "Pointer", "Rect", "daemon", "latency", "pixels", "uinput"] +__all__ = [ + "Checks", + "Kde", + "Machine", + "Outputs", + "Pointer", + "Rect", + "daemon", + "latency", + "pixels", + "uinput", +] diff --git a/nix/tests/vm/lib/komorebi_vm/kde.py b/nix/tests/vm/lib/komorebi_vm/kde.py index 3f430586..b38115dd 100644 --- a/nix/tests/vm/lib/komorebi_vm/kde.py +++ b/nix/tests/vm/lib/komorebi_vm/kde.py @@ -31,6 +31,57 @@ class Rect: and other.y < self.y + self.h ) + def inside(self, area: "Rect", slack: int = 0) -> bool: + """Returns true if and only if every pixel of this rectangle is a pixel of `area`, give + or take `slack` pixels at each edge.""" + return ( + self.x >= area.x - slack + and self.y >= area.y - slack + and self.x + self.w <= area.x + area.w + slack + and self.y + self.h <= area.y + area.h + slack + ) + + +class Outputs: + """The outputs of a KWin session, as `kscreen-doctor` lists and changes them. + + A [`Kde`][] has one, as [`Kde.outputs`][]. Calling it gives the number of outputs KWin has + enabled. An output is named as KWin names it (`Virtual-0`), and a disabled output keeps its + name, so that it can be enabled again. + """ + + def __init__(self, kde: "Kde") -> None: + self._kde = kde + + def __call__(self) -> int: + """The number of outputs KWin has enabled.""" + return int( + self._kde.sh("kscreen-doctor -j | jq '[.outputs[] | select(.enabled)] | length'") + ) + + def names(self, *, enabled: bool = True) -> list[str]: + """The names of the outputs that are enabled (or, with `enabled` false, disabled), from + left to right.""" + listed = self._kde.sh( + f"kscreen-doctor -j | jq -r '[.outputs[] | select(.enabled == {str(enabled).lower()})]" + " | sort_by(.pos.x) | .[].name'" + ) + return listed.split() + + def disable(self, name: str) -> None: + """Unplugs an output, as far as KWin can tell: it is disabled, and its windows go to the + outputs that are left.""" + self._kde.sh(f"kscreen-doctor output.{name}.disable") + + def enable(self, name: str) -> None: + """Plugs an output back in, as far as KWin can tell.""" + self._kde.sh(f"kscreen-doctor output.{name}.enable") + + def scale(self, name: str, factor: float) -> None: + """Gives an output another scale: its size in pixels, as the windows see it, becomes + its mode divided by `factor`.""" + self._kde.sh(f"kscreen-doctor output.{name}.scale.{factor:g}") + class Kde: """One KWin session of the test user, with its own session bus and Wayland socket. @@ -455,7 +506,11 @@ class Kde: return self.run(f"kdotool search --class {pattern} | head -1")[1].strip() def rect(self, wid: str) -> Rect: - """The position and size KWin reports for the window.""" + """The position and size KWin reports for the window, rounded to whole pixels. + + At a fractional scale the frame is not a whole number of pixels in the logical + coordinates KWin reports. + """ out = self.sh(f"kdotool getwindowgeometry {shlex.quote(wid)}") position = size = None for line in out.splitlines(): @@ -466,8 +521,8 @@ class Kde: size = words[1] if position is None or size is None: raise AssertionError(f"no geometry in {out!r}") - x, y = (int(v) for v in position.split(",")) - w, h = (int(v) for v in size.split("x")) + x, y = (round(float(v)) for v in position.split(",")) + w, h = (round(float(v)) for v in size.split("x")) return Rect(x, y, w, h) def title(self, wid: str) -> str: @@ -625,9 +680,11 @@ class Kde: mark = f"KS:{name} " return [line.split(mark, 1)[1] for line in log.splitlines() if mark in line] - def outputs(self) -> int: - """The number of outputs KWin has enabled.""" - return int(self.sh("kscreen-doctor -j | jq '[.outputs[] | select(.enabled)] | length'")) + @property + def outputs(self) -> Outputs: + """The session's outputs: `kde.outputs()` is how many are enabled, and `kde.outputs` has + the ones to enable, disable and scale ([`Outputs`][]).""" + return Outputs(self) def _effects(self, method: str, name: str) -> bool: """Asks KWin's `/Effects` object a yes or no question about the effect `name`.""" diff --git a/nix/tests/vm/lib/tests/test_kde.py b/nix/tests/vm/lib/tests/test_kde.py index cc8b009f..c3227bc0 100644 --- a/nix/tests/vm/lib/tests/test_kde.py +++ b/nix/tests/vm/lib/tests/test_kde.py @@ -3,7 +3,7 @@ import shlex import pytest -from komorebi_vm import Kde, Rect +from komorebi_vm import Kde, Outputs, Rect from .conftest import FakeMachine @@ -75,6 +75,15 @@ def test_rect_parses_what_kdotool_prints(machine: FakeMachine) -> None: assert Kde(machine, "main").rect("{abc}") == Rect(10, 20, 300, 400) +def test_rect_rounds_what_a_fractional_scale_makes_of_a_frame(machine: FakeMachine) -> None: + machine.reply( + "getwindowgeometry", + output="Window {abc}\n Position: 0.4,10.5\n Geometry: 695.3333333333334x400.6\n", + ) + + assert Kde(machine, "main").rect("{abc}") == Rect(0, 10, 695, 401) + + def test_rect_fails_on_output_it_does_not_understand(machine: FakeMachine) -> None: machine.reply("getwindowgeometry", output="nothing useful") @@ -716,3 +725,53 @@ def test_a_kwin_script_that_never_reports_it_is_done_fails_the_test(machine: Fak with pytest.raises(AssertionError, match="never reported"): kde.kwin_script("silent", "") + + +def test_a_rect_is_inside_an_area_that_has_every_one_of_its_pixels() -> None: + area = Rect(0, 0, 1920, 1080) + + assert Rect(0, 0, 1920, 1080).inside(area) + assert Rect(100, 50, 800, 600).inside(area) + assert not Rect(-1, 0, 100, 100).inside(area) + assert not Rect(1900, 0, 100, 100).inside(area) + assert not Rect(0, 1000, 100, 100).inside(area) + assert Rect(1920, 0, 800, 600).inside(Rect(1920, 0, 1920, 1080)) + assert Rect(-1, 0, 1921, 1080).inside(area, slack=1) + assert not Rect(-2, 0, 100, 100).inside(area, slack=1) + + +def test_outputs_count_the_enabled_ones_when_called(machine: FakeMachine) -> None: + machine.reply("length", output="2\n") + + assert Kde(machine, "main").outputs() == 2 + assert isinstance(Kde(machine, "main").outputs, Outputs) + + +def test_outputs_are_named_left_to_right_and_a_disabled_one_keeps_its_name( + machine: FakeMachine, +) -> None: + machine.reply("select(.enabled == true)", output="Virtual-0\nVirtual-1\n") + machine.reply("select(.enabled == false)", output="Virtual-1\n") + outputs = Kde(machine, "main").outputs + + assert outputs.names() == ["Virtual-0", "Virtual-1"] + assert outputs.names(enabled=False) == ["Virtual-1"] + assert all("sort_by(.pos.x)" in inner(command) for command in machine.commands) + + +def test_an_output_is_disabled_enabled_and_scaled_with_kscreen_doctor( + machine: FakeMachine, +) -> None: + outputs = Kde(machine, "main").outputs + + outputs.disable("Virtual-1") + outputs.enable("Virtual-1") + outputs.scale("Virtual-0", 1.5) + outputs.scale("Virtual-0", 1) + + assert [inner(command) for command in machine.commands] == [ + "kscreen-doctor output.Virtual-1.disable", + "kscreen-doctor output.Virtual-1.enable", + "kscreen-doctor output.Virtual-0.scale.1.5", + "kscreen-doctor output.Virtual-0.scale.1", + ] diff --git a/nix/tests/vm/suites/hotplug/default.nix b/nix/tests/vm/suites/hotplug/default.nix new file mode 100644 index 00000000..b51bc717 --- /dev/null +++ b/nix/tests/vm/suites/hotplug/default.nix @@ -0,0 +1,5 @@ +{ + tier = "fast"; + kind = "headless"; + timeoutMinutes = 15; +} diff --git a/nix/tests/vm/suites/hotplug/test.py b/nix/tests/vm/suites/hotplug/test.py new file mode 100644 index 00000000..8d1a5d1e --- /dev/null +++ b/nix/tests/vm/suites/hotplug/test.py @@ -0,0 +1,98 @@ +# Monitors that change, against a real headless KWin with two outputs. What only a real KWin can +# tell: that `kscreen-doctor` changing the scale of an output reaches komorebi as a change of that +# monitor's size, that the windows on it are tiled again in the size it has now, and that they +# fill it again when it has its size back. `komorebi/tests/monitor_resize.rs` checks the same +# against the fake. +from komorebi_vm import Checks, Kde, Rect + +W, H = 1920, 1080 + +start_all() +machine.wait_for_unit("multi-user.target") +machine.wait_for_unit("user@1000.service") + +checks = Checks() +kde = Kde.headless(machine, "main", size=(W, H), outputs=2, scale=SCALE) + +TITLES = ["w1", "w2", "w3"] + + +def monitor(idx: int) -> Rect: + """The bounds komorebi has for a monitor.""" + size = kde.state()["monitors"]["elements"][idx]["size"] + return Rect(size["left"], size["top"], size["right"], size["bottom"]) + + +def tiled_in(area: Rect) -> bool: + """Every one of the three windows is inside `area`, and none is on another. + + A pixel is allowed at each edge: at a fractional scale KWin reports frames that are not + whole pixels. + """ + rects = [kde.rect(kde.wid(title)) for title in TITLES] + return all(r.inside(area, slack=1) for r in rects) and not any( + a.overlaps(b) for i, a in enumerate(rects) for b in rects[i + 1 :] + ) + + +def resizes_handled() -> int: + """How many times the daemon has read the sizes of its monitors again.""" + return kde.komorebi_log().count("updated monitor size and work area") + + +print("== 1. two outputs, and three windows on the left one") +left, right = kde.outputs.names() +print(f" outputs: {left} (left), {right} (right)") +kde.start_komorebi() +checks.check( + "the daemon answers komorebic, with both monitors", + kde.wait_for( + lambda: kde.run("komorebic state")[0] == 0 + and len(kde.state()["monitors"]["elements"]) == 2, + timeout=30, + ), +) +for title in TITLES: + kde.spawn(f"foot -T {title} sleep 3000") + kde.wait_for(lambda title=title: kde.wid(title) != "", timeout=30) +checks.check( + "komorebi has the three windows on the left monitor", + kde.wait_for(lambda: kde.managed(0) == 3), +) +checks.check("the left monitor has the size of its output", monitor(0) == Rect(0, 0, W, H)) +checks.check("the windows are tiled inside it", kde.wait_for(lambda: tiled_in(monitor(0)))) + +print("== 2. the scale of the left output changes") +before = resizes_handled() +kde.outputs.scale(left, 1.5) +smaller = Rect(0, 0, W * 2 // 3, H * 2 // 3) +checks.check( + f"komorebi has the new size of the monitor, {smaller}", + kde.wait_for(lambda: monitor(0) == smaller, timeout=20), +) +checks.check("it read the sizes again for it", resizes_handled() > before) +checks.check( + "the windows are tiled again inside the monitor as it is now", + kde.wait_for(lambda: tiled_in(smaller)), +) + +print("== 3. and back") +kde.outputs.scale(left, 1) +checks.check( + "the monitor has its size back", + kde.wait_for(lambda: monitor(0) == Rect(0, 0, W, H), timeout=20), +) +checks.check( + "the windows fill it again, side by side", + kde.wait_for(lambda: tiled_in(Rect(0, 0, W, H)) and kde.rect(kde.wid("w1")).w < W - 100), +) + +print("== 4. nothing is left behind") +checks.check( + "komorebi is running and answers", + kde.komorebi_running() and kde.run("komorebic state")[0] == 0, +) +kde.stop_komorebi() +checks.check("komorebi stopped", not kde.komorebi_running()) + +checks.finish()