From 40eec2f6beedcafddce3da85fcc5098c7a356d18 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 20:29:54 -0700 Subject: [PATCH] feat(wm): follow a window moved to another output A window can be moved to another output by something other than komorebi: a KWin shortcut that sends it to the next screen, the window menu, an application. komorebi had it in a container of the old monitor, whose tile is on a screen the window is not on, so it sat untiled where it landed. The bridge now reports a window's output changing, with the frame the window has by then, since the signal and the frame arrive in no fixed order, and the table makes it an OutputChange event. The window manager handles it with WindowManager::follow_windows_to_their_monitors: every container whose focused window, and every floating window, is on another monitor than the one it has it on moves whole to the workspace of the same number on the monitor that holds the middle of its frame, and the monitors that changed are tiled. Every window is looked at, not only the one in the event, and a move komorebi made itself is in its state already and finds nothing to do. Nothing is followed while the user drags a window: the end of the drag decides where that window goes, so the method returns at once while a move or a resize by the user is going on. KWin also moves windows for a moment after outputs come and go, so the monitor reconciliator looks again 1.5 seconds after each notification, where it used to trust what it found at once. vm-hotplug sends windows over with kdotool as well as with komorebic. --- README.md | 4 +- docs/dev/ARCHITECTURE.md | 40 +++- docs/dev/DESIGN.md | 13 ++ docs/dev/PLATFORM.md | 5 + docs/usage/changing-monitors.md | 21 ++ docs/usage/subscribing-to-notifications.md | 6 +- komorebi/src/kwin/bridge.js | 30 ++- komorebi/src/kwin/bridge.test.js | 25 +++ komorebi/src/kwin/fixtures/events.json | 4 + komorebi/src/kwin/kwin-api.d.ts | 4 + komorebi/src/kwin/state.rs | 95 +++++++- komorebi/src/lib.rs | 3 +- komorebi/src/monitor_reconciliator.rs | 46 +++- komorebi/src/process_event.rs | 8 + komorebi/src/reconcile.rs | 226 ++++++++++++++++++- komorebi/src/window_manager_event.rs | 19 ++ komorebi/tests/follow_output.rs | 238 +++++++++++++++++++++ nix/tests/vm/suites/hotplug/test.py | 100 +++++++-- 18 files changed, 825 insertions(+), 62 deletions(-) create mode 100644 komorebi/tests/follow_output.rs diff --git a/README.md b/README.md index 66e9f139..764cafbd 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,9 @@ workspace in front of the focused monitor in the area it now has. When an output is unplugged, KWin takes its windows to the outputs that are left, and the daemon takes the monitor's containers over to the monitors the windows are on, tiles them there, and keeps the monitor, with its settings, for when it is -plugged in again. +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. 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 diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index d2cd203b..bb4f5b87 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -49,9 +49,10 @@ grows with the code: each part of the daemon that is added has its place here. container, so it has left the layout, until it is put back in a container of its own where it was ([`Workspace::maximized_window`]). - **Event**: what the compositor reports about a window: it was shown, destroyed, given - the focus, minimized or restored, dragged by the user, or put on another workspace's desktop; - and that a monitor shows another workspace ([`WindowManagerEvent`]). Two events come from - komorebi itself: a command asks it to manage the focused window, or to stop managing it. + the focus, minimized or restored, dragged by the user, put on another workspace's desktop, or + moved to another output; and that a monitor shows another workspace + ([`WindowManagerEvent`]). Two events come from komorebi itself: a command asks it to manage the + focused window, or to stop managing it. - **Message**: what a client sends the daemon on its socket, a [`SocketMessage`]: an order (focus a window, move it, change the layout, resize it, show a workspace, retile, stop) or a question (the state, the monitors, one [`StateQuery`]). @@ -361,6 +362,23 @@ added again. The configuration fills the cache as well: [`StaticConfig::postload preference names, and make a monitor from the entry of each preference whose monitor is not connected, so that the monitor takes its entry when it is plugged in. +The compositor can also move a window to another output without the set of outputs changing: a +shortcut that sends it to the next screen, the window menu, an application. The bridge reports +that from the window's `outputChanged` signal as an `output` event, with the frame the window +has by then, since the signal and the frame arrive in no fixed order, and the table makes it an +`OutputChange` event for the window manager. KWin also moves windows for a moment after outputs +come and go, so the reconciliator looks again 1.5 seconds after each notification +([`settle_later`]; a burst of notifications makes one look). Both end in +[`WindowManager::follow_windows_to_their_monitors`]: it finds every container whose focused +window, and every floating window, is on another monitor than the one the window manager has it +on, and moves it, whole, to the workspace of the same number on the monitor that holds the +middle of its frame, creating workspaces if that monitor has fewer, and tiles the monitors that +windows moved between. The window manager reads every window, not only the one in the event, +since when one moved others may have, and a move komorebi made itself is in its state already +and finds nothing. While the user drags a window nothing moves, since the end of the drag decides +where that window goes: the method returns at once while [`WindowManager::pending_move_op`] or +[`WindowManager::pending_resize_op`] holds a drag. + ## The command socket The daemon takes messages on a Unix socket, `komorebi.sock` in [`DATA_DIR`] @@ -1109,7 +1127,8 @@ module's unit. takes them, and [`reconcile_display_change`], which brings the window manager's monitors in line with the outputs; [`reconcile`](../../komorebi/src/reconcile.rs): moves what was on a monitor that went to the - monitors that are left. + 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::kwinrc`](../../komorebi/src/kwin/kwinrc.rs), the editor of the user's `kwinrc`, which @@ -1276,6 +1295,9 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | 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 | `plugging_in_a_monitor_is_a_connection_change_and_a_new_panel_is_a_resize`, `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 window moved to another output by something other than komorebi is tiled on that monitor | framework path: the bridge reports an output change, the table makes it an `OutputChange` event, and [`WindowManager::follow_windows_to_their_monitors`] moves the containers whose windows are on another monitor | `a_window_sent_to_another_screen_by_a_shortcut_is_tiled_there`, `a_window_on_another_output_raises_an_output_change_with_the_frame_it_has_there`, `a window that is on another output is reported with the frame it has and the output's name`, `vm-hotplug` ("komorebi follows it") | none | +| A move komorebi made itself is not undone when KWin reports it | framework path: [`WindowManager::follow_windows_to_their_monitors`] compares the frame of each window with the monitor the window manager has it on, and finds them equal | `vm-hotplug` ("still on the right monitor once KWin has said so") | none | +| Nothing is moved while the user drags a window | framework path: [`WindowManager::follow_windows_to_their_monitors`] returns at once while [`WindowManager::pending_move_op`] or [`WindowManager::pending_resize_op`] holds a drag | `nothing_is_followed_while_the_user_drags_a_window` | none | | The windows of a monitor that is unplugged are tiled on the monitors that are left | framework path: [`reconcile_display_change`] has [`WindowManager::evacuate_monitor`] move each container and floating window to the monitor that holds it, before it removes the monitor | `the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left`, `vm-hotplug` ("tiled inside the screen that is left") | none | | A stack moves with its monitor as a stack | framework path: [`WindowManager::evacuate_monitor`] moves whole containers | `a_stack_goes_with_the_monitor_as_a_stack` | none | | A monocle or a maximized window of a monitor that goes is not lost | framework path: [`WindowManager::evacuate_monitor`] takes both out of the workspace and puts each in a container on the monitor that is left | `a_monocle_of_a_monitor_that_goes_comes_back_as_a_container_with_the_windows_it_hid`, `a_maximized_window_of_a_monitor_that_goes_is_not_lost` | none | @@ -1508,9 +1530,11 @@ 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) and - [`komorebi/tests/hotplug.rs`](../../komorebi/tests/hotplug.rs): a monitor that changes size, - and monitors that are unplugged and plugged in, against the fake; +- [`komorebi/tests/monitor_resize.rs`](../../komorebi/tests/monitor_resize.rs), + [`komorebi/tests/hotplug.rs`](../../komorebi/tests/hotplug.rs) and + [`komorebi/tests/follow_output.rs`](../../komorebi/tests/follow_output.rs): a monitor that + changes size, monitors that are unplugged and plugged in, and windows that move to another + 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/workspaces.rs`](../../komorebi/tests/workspaces.rs): workspaces as the @@ -1752,6 +1776,8 @@ A window is shown, and ends up on a tile: [`reconcile_display_change`]: ../../komorebi/src/monitor_reconciliator.rs [`insert_in_monitor_cache`]: ../../komorebi/src/monitor_reconciliator.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 [`NotificationEvent::Monitor`]: ../../komorebi/src/lib.rs [`bridge.js`]: ../../komorebi/src/kwin/bridge.js [`kwin-api.d.ts`]: ../../komorebi/src/kwin/kwin-api.d.ts diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index 16e4a77d..e729f159 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -611,3 +611,16 @@ and tiles the monitors that changed. It keeps the monitor that went, with its workspaces and settings, so that an output that is plugged in again gets them back: the windows the monitor had are tiled elsewhere by then, and it comes back without them. + +## Why does komorebi look at every window again a moment after the monitors change? + +Because KWin moves windows about for a moment after the outputs change, so what it reports +first is not where the windows end up. An output that went takes its windows to another, one +that is plugged back may take them again, and a change of scale or resolution changes where the +middle of a window is. The monitor reconciliator therefore waits 1.5 seconds after a change of +the monitors (a burst of changes makes one wait) and then finds every container whose window is +on another monitor than the one komorebi has it on, and follows it there. A window that KWin +reports on another output is handled the same way, looking at every window and not only that +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. diff --git a/docs/dev/PLATFORM.md b/docs/dev/PLATFORM.md index 37b30fa5..6b09a193 100644 --- a/docs/dev/PLATFORM.md +++ b/docs/dev/PLATFORM.md @@ -442,6 +442,11 @@ with two outputs of 1920x1080: how it gets it back. The script reports the outputs that are left after the first and all of them after the second, and komorebi follows both. A disabled output is enabled again by the name it had. +- A window that a script moves so that the middle of its frame is on the other output is on + that output: `Window.output` follows the middle of the frame (found in the spikes with two + outputs on KWin 6.8.1). Its desktops are not changed by the move, so it stays on the desktop + 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. ## How long a retile takes diff --git a/docs/usage/changing-monitors.md b/docs/usage/changing-monitors.md index 10b0c42f..9fe8f747 100644 --- a/docs/usage/changing-monitors.md +++ b/docs/usage/changing-monitors.md @@ -18,6 +18,22 @@ KWin has no signal for the room a panel leaves, so komorebi looks at it when a p closes or moves, and once a second. A panel that is laid out a moment after komorebi starts is found within a second. +## A window is moved to another monitor + +A window moves to another monitor when something other than komorebi moves it: a KWin shortcut +that sends the window to the next screen, the window menu, a drag with the pointer, or an +application. KWin tells komorebi, which finds every container and floating window that is on +another monitor than the one it has it on, and moves it to the workspace with the same number on +the monitor it is on. The monitors that windows moved between are tiled again. + +A window is on the monitor whose area has the middle of its frame. A container is on the monitor +of the window that has the focus in it. A move komorebi made itself, with +[`komorebic move-to-monitor`](../cli/move-to-monitor.md) for one, is in komorebi's state already, +and finds nothing to do. + +While the user drags a window with the pointer, komorebi moves nothing: where the window goes is +decided when they let go of it ([moving windows by dragging](moving-windows.md#by-dragging)). + ## A monitor is unplugged KWin takes the windows of an output that goes (it is unplugged, or disabled with @@ -28,6 +44,11 @@ the workspaces that changed are tiled again. A [stack](stacking-windows.md) goes monocle window and a maximized window come as tiles of their own, and the windows the monocle hid are shown again. +A moment after an output goes or comes back (1.5 seconds), komorebi looks at where every window +is once more, because KWin moves windows about for a while after the outputs change: the windows +it took to the monitor that is left may go back to the output that is plugged in again, and a +change of resolution or scale changes where the middle of a window is. + komorebi keeps the monitor that went, with its workspaces, layouts, paddings and offsets, for when it comes back, and forgets the [workspace rules](workspace-rules.md) of its workspaces until then. When no output is left at all, komorebi does nothing. diff --git a/docs/usage/subscribing-to-notifications.md b/docs/usage/subscribing-to-notifications.md index 4cc04a6f..3bb80675 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), 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. +was shown, closed, focused, minimized, restored or moved to another output), 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 diff --git a/komorebi/src/kwin/bridge.js b/komorebi/src/kwin/bridge.js index 9ea22977..36214db9 100644 --- a/komorebi/src/kwin/bridge.js +++ b/komorebi/src/kwin/bridge.js @@ -52,14 +52,16 @@ * 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; - * `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 - * frame the user asks for part way through a resize, at most one every 16 ms; `cursor` answers a - * cursor query, and `failed` gives back the commands of a batch that could not be carried out. + * `output` says that a window is on another output, by the output's name (empty for none), with + * the frame the window has there; `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 frame the user asks for part way through a resize, at most one every 16 ms; `cursor` + * answers a cursor query, and `failed` gives back the commands of a batch that could not be + * carried out. * * @typedef {{ * type: "snapshot", windows: WindowInfo[], screens: Screen[], desktops: Desktop[], @@ -71,6 +73,7 @@ * @typedef {{ type: "minimized", id: string, v: boolean }} Minimized * @typedef {{ type: "geometry", id: string, geo: Rect }} Geometry * @typedef {{ type: "screens", screens: Screen[] }} Screens + * @typedef {{ type: "output", id: string, geo: Rect, output: string }} OutputChange * @typedef {{ type: "caption", id: string, caption: string }} Caption * @typedef {{ type: "desktops", id: string, desktops: string[] }} Desktops * @typedef {{ type: "desktop_list", desktops: Desktop[] }} DesktopList @@ -84,9 +87,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 | Screens | Caption - * | Desktops | DesktopList | CurrentDesktops | MoveResizeStarted | MoveResizeFinished - * | MoveResizeStep | CursorAnswer | Failed} BridgeEvent + * @typedef {Snapshot | Added | Removed | Activated | Minimized | Geometry | Screens | OutputChange + * | Caption | Desktops | DesktopList | CurrentDesktops | MoveResizeStarted + * | MoveResizeFinished | MoveResizeStep | CursorAnswer | Failed} BridgeEvent */ /** @@ -440,6 +443,11 @@ function track(w, announce) { w.minimizedChanged.connect(function () { emit("minimized", { id: id, v: !!w.minimized }); }); + // moved to another output by somebody else: the user's shortcut, or KWin when an output goes + // away (its geometry goes with it, as the two arrive in no fixed order) + w.outputChanged.connect(function () { + emit("output", { id: id, geo: rect(w.frameGeometry), output: w.output ? String(w.output.name) : "" }); + }); w.captionChanged.connect(function () { emit("caption", { id: id, caption: String(w.caption) }); }); diff --git a/komorebi/src/kwin/bridge.test.js b/komorebi/src/kwin/bridge.test.js index 9ef08cdc..195beb92 100644 --- a/komorebi/src/kwin/bridge.test.js +++ b/komorebi/src/kwin/bridge.test.js @@ -92,8 +92,10 @@ function window({ }, move: false, resize: false, + output: null, frameGeometryChanged: new Signal(), minimizedChanged: new Signal(), + outputChanged: new Signal(), captionChanged: new Signal(), desktopsChanged: new Signal(), interactiveMoveResizeStarted: new Signal(), @@ -394,6 +396,10 @@ test("what the bridge sends for a session is what the daemon's tests read", () = screen.placementArea = geometry(0, 0, 1920, 1000); k.workspace.screensChanged.emit(); + // and somebody sends the editor to another output + editor.output = { name: "DP-2" }; + editor.outputChanged.emit(); + assert.deepStrictEqual(k.events(), events); assert.deepStrictEqual(editor.frameGeometry, geometry(40, 30, 1040, 1050)); assert.equal(k.workspace.activeWindow, editor); @@ -480,6 +486,25 @@ test("the outputs are looked at once a second, which finds a panel that starts a ); }); +test("a window that is on another output is reported with the frame it has and the output's name", () => { + const w = window({ id: "{w}", frameGeometry: geometry(100, 100, 800, 600) }); + const k = kwin({ windows: [w] }); + + w.frameGeometry = geometry(2100.4, 300, 800, 600); + w.output = { name: "DP-2" }; + w.outputChanged.emit(); + w.output = null; + w.outputChanged.emit(); + + assert.deepStrictEqual( + k.events().slice(1).filter((e) => e.type === "output"), + [ + { type: "output", id: "{w}", geo: { x: 2100, y: 300, w: 800, h: 600 }, output: "DP-2", seq: 3 }, + { type: "output", id: "{w}", geo: { x: 2100, y: 300, w: 800, h: 600 }, output: "", seq: 4 }, + ], + ); +}); + test("the desktop each output shows is sent when it changes, and once", () => { const k = kwin(); const [first, second] = k.workspace.desktops; diff --git a/komorebi/src/kwin/fixtures/events.json b/komorebi/src/kwin/fixtures/events.json index 0b70b78e..eee0bbd3 100644 --- a/komorebi/src/kwin/fixtures/events.json +++ b/komorebi/src/kwin/fixtures/events.json @@ -120,5 +120,9 @@ "area": {"x": 0, "y": 0, "w": 1920, "h": 1000} } ] + }, + { + "type": "output", "seq": 29, "id": "{0e5a1c3b-0000-4000-8000-00000000000a}", + "geo": {"x": 40, "y": 30, "w": 1040, "h": 1050}, "output": "DP-2" } ] diff --git a/komorebi/src/kwin/kwin-api.d.ts b/komorebi/src/kwin/kwin-api.d.ts index b41dd586..53745733 100644 --- a/komorebi/src/kwin/kwin-api.d.ts +++ b/komorebi/src/kwin/kwin-api.d.ts @@ -78,6 +78,9 @@ interface KWinWindow { minimized: boolean; /** How opaque the window is drawn, 0 to 1: 1 is not see-through at all. */ opacity: number; + /** The output the window is on, which is the one that has the middle of its frame; `null` when + * there is none. */ + readonly output: Output | null; readonly fullScreen: boolean; /** `KWin::MaximizeMode`: 0 restored, 1 vertically, 2 horizontally, 3 both ways. */ readonly maximizeMode: number; @@ -93,6 +96,7 @@ interface KWinWindow { desktops: VirtualDesktop[]; readonly frameGeometryChanged: Signal<[oldGeometry: QRectF]>; readonly minimizedChanged: Signal; + readonly outputChanged: Signal; readonly captionChanged: Signal; readonly desktopsChanged: Signal; /** The user took hold of the window with the pointer, to move it (`move`) or resize it diff --git a/komorebi/src/kwin/state.rs b/komorebi/src/kwin/state.rs index d2cb862a..7947b157 100644 --- a/komorebi/src/kwin/state.rs +++ b/komorebi/src/kwin/state.rs @@ -674,6 +674,9 @@ impl Table { /// with no `id`, took it away from every window. /// - `minimized`: a window was minimized or brought back ([`WindowManagerEvent::Minimize`], /// [`WindowManagerEvent::Restore`]). + /// - `output`: a window is on another output. It carries the frame the window has now, which + /// goes with the output, since the two events arrive in no fixed order + /// ([`WindowManagerEvent::OutputChange`]). /// - `caption`: a window's title changed ([`WindowManagerEvent::TitleUpdate`]). /// - `geometry`: a window's frame changed. It raises no event. /// - `desktops`: a window is on other desktops. When it is now on another workspace than the @@ -716,6 +719,7 @@ impl Table { Some("activated") => self.on_activated(event), Some("minimized") => self.on_minimized(event), Some("move_resize_step") => self.on_drag_step(event), + Some("output") => self.on_output(event), Some("caption") => self.on_caption(event), Some("geometry") => { if let Some(entry) = self.entry_mut(event) { @@ -959,6 +963,30 @@ impl Table { })] } + fn on_output(&mut self, event: &Value) -> Vec { + let Some(id) = self.id_of(event) else { + return vec![]; + }; + + let Some(entry) = self.windows.get_mut(&id) else { + return vec![]; + }; + + // the frame that goes with the output (the two events arrive in no fixed order) + if event["geo"].is_object() { + entry.info.rect = rect(&event["geo"]); + } + + if !entry.info.normal { + return vec![]; + } + + vec![Output::Event(WindowManagerEvent::OutputChange( + kwin(KWinNotification::WindowOutputChanged), + Window::from(id), + ))] + } + fn on_caption(&mut self, event: &Value) -> Vec { let Some(id) = self.id_of(event) else { return vec![]; @@ -1335,6 +1363,18 @@ mod tests { t.switch_commands(monitor, 1).unwrap(); } + // the end of the resize is where the user took the edge, and the window's frame is + // the one KWin has: the frame at the end of the session is the one the editor has on + // the other output + if event["seq"] == 27 { + assert_eq!( + t.drag_end(1).unwrap().right, + 1100, + "where the user took the edge" + ); + assert_eq!(t.window(1).unwrap().rect.right, 1040, "the frame KWin has"); + } + if event["type"] == "added" && event["caption"] == "a terminal" { let w = t.window(2).unwrap(); assert_eq!( @@ -1390,6 +1430,8 @@ mod tests { "move_resize_finished -> ResizeEnd 1", // a panel takes room from the monitor "screens -> Monitor Resize", + // somebody moves the editor to another output + "output -> OutputChange 1", ] ); @@ -1397,11 +1439,9 @@ mod tests { let windows = t.windows(); assert_eq!(windows.len(), 1); assert_eq!(windows[0].title, "notes.txt — Kate"); - assert_eq!(windows[0].rect.right, 1040, "the frame KWin has"); assert_eq!( - t.drag_end(1).unwrap().right, - 1100, - "where the user took the edge" + windows[0].rect.right, 1040, + "the frame that goes with the output" ); assert_eq!(t.window(3).map(|w| w.alive), Some(false)); assert_eq!(t.window(2).unwrap().rect.bottom, 532, "the geometry event"); @@ -1615,6 +1655,53 @@ mod tests { ); } + #[test] + fn a_window_on_another_output_raises_an_output_change_with_the_frame_it_has_there() { + let mut t = table(vec![win("a", &["d1"])]); + let now = Instant::now(); + + let out = t.handle( + &json!({ + "type": "output", "id": "a", "output": "DP-2", + "geo": { "x": 2100, "y": 300, "w": 800, "h": 600 }, + }), + now, + ); + + assert_eq!(events(&out), vec!["OutputChange 1"]); + let window = t.window(1).unwrap(); + assert_eq!( + window.rect, + Rect { + left: 2100, + top: 300, + right: 800, + bottom: 600 + } + ); + } + + #[test] + fn an_output_change_of_a_dialog_or_of_a_window_nobody_knows_raises_nothing() { + let mut t = table(vec![]); + t.handle( + &json!({ + "type": "added", "id": "dialog", "pid": 0, "class": "foot", "caption": "Save As", + "normal": false, "minimized": false, "fullscreen": false, + "geo": { "x": 0, "y": 0, "w": 100, "h": 100 }, "desktops": ["d1"], + }), + Instant::now(), + ); + + for id in ["dialog", "nobody"] { + let out = t.handle( + &json!({ "type": "output", "id": id, "output": "DP-2" }), + Instant::now(), + ); + assert!(out.is_empty(), "{out:?}"); + } + } + #[test] fn a_title_change_raises_a_title_update() { let mut t = table(vec![win("a", &["d1"])]); diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 33473fd6..86ad91b1 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -21,7 +21,8 @@ //! 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. When a monitor is unplugged it //! moves what was on it to the monitors that are left ([`reconcile`]), keeps the monitor for when -//! it is plugged in again, and adds a monitor it has not seen. +//! it is plugged in again, and adds a monitor it has not seen. A window that something else moves +//! to another output is followed to that monitor ([`reconcile`]). //! //! 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 diff --git a/komorebi/src/monitor_reconciliator.rs b/komorebi/src/monitor_reconciliator.rs index 3b73c27f..dd05f4d0 100644 --- a/komorebi/src/monitor_reconciliator.rs +++ b/komorebi/src/monitor_reconciliator.rs @@ -13,14 +13,16 @@ //! //! 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 -//! dropped, and the rules of the workspaces of one that comes back apply again. +//! dropped, and the rules of the workspaces of one that comes back apply again. A moment after each +//! notification, the windows are followed to the monitors the compositor moved them to +//! ([`WindowManager::follow_windows_to_their_monitors`]). //! //! 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` and `tests/hotplug.rs` against the fake backend, and by -//! the `vm-hotplug` VM suite. +//! 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. use crate::DISPLAY_INDEX_PREFERENCES; use crate::Notification; @@ -41,6 +43,9 @@ use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; use strum::Display; /// A change of the monitors, sent to the monitor reconciliator with [`send_notification`]. @@ -97,6 +102,34 @@ pub fn insert_in_monitor_cache(serial_number_id: &str, monitor: Monitor) { monitor_cache.insert(preferred_id, monitor); } +/// Whether a [`settle_later`] is waiting, so that a burst of notifications makes one. +static SETTLING: AtomicBool = AtomicBool::new(false); + +/// The compositor moves windows about for a moment after the monitors change: an output that went +/// takes its windows to another, one that came back may take them again, a scale or a resolution +/// changes where the middle of a window is. A moment later (1.5 seconds), see where they all are, +/// with [`WindowManager::follow_windows_to_their_monitors`]. A call made while one is waiting +/// does nothing. +fn settle_later(wm: Arc>) { + if SETTLING.swap(true, Ordering::SeqCst) { + return; + } + + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1500)); + SETTLING.store(false, Ordering::SeqCst); + + let mut wm = wm.lock(); + match wm.follow_windows_to_their_monitors() { + Ok(true) => tracing::info!("windows followed the monitors they were moved to"), + Ok(false) => {} + Err(error) => { + tracing::error!("could not follow the windows to their monitors: {error}") + } + } + }); +} + /// 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) { @@ -458,8 +491,9 @@ pub fn reconcile_display_change(wm: &mut WindowManager) -> color_eyre::Result<() /// 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. +/// Each is dealt with as the [module docs](self) say, and a moment later the windows are followed +/// to their monitors. The subscribers are then notified, and the backend is asked for the look +/// again. /// /// # Errors /// @@ -492,6 +526,8 @@ pub fn handle_notifications(wm: Arc>) -> color_eyre::Result } } + settle_later(wm.clone()); + { let wm = wm.lock(); notify_subscribers( diff --git a/komorebi/src/process_event.rs b/komorebi/src/process_event.rs index bf694048..e3a5465c 100644 --- a/komorebi/src/process_event.rs +++ b/komorebi/src/process_event.rs @@ -47,6 +47,11 @@ //! ([`WindowManager::resize_window`]), and the workspace is tiled again. **ResizeStep**, part //! way through, tiles the workspace as if the drag ended there, from the layout the drag began //! on, and leaves the dragged window where the user has it. +//! - **OutputChange**: a window is on another output than it was, because the user moved it (a +//! shortcut that sends a window to the next screen) or KWin did (an output went away or came +//! back). The containers and floating windows of every monitor follow their windows to the +//! monitors they are on, and the monitors that changed are tiled again +//! ([`WindowManager::follow_window_to_its_monitor`]). //! - **WorkspaceChange**: a monitor shows another workspace, because something other than komorebi //! switched it (a pager, a shortcut, an app raising a window of another desktop). The window //! manager follows ([`WindowManager::adopt_workspace_change`]) and asks the compositor for @@ -288,6 +293,9 @@ impl WindowManager { WindowManagerEvent::WorkspaceChange(..) => {} // handled before the window is looked up WindowManagerEvent::ResizeStep(..) => {} + WindowManagerEvent::OutputChange(..) => { + self.follow_window_to_its_monitor(window)?; + } WindowManagerEvent::WorkspaceMove(_, _, idx) => { // somebody put the window on another workspace's desktop: it belongs there now. // (komorebi's own moves come back here too, and find the window already there.) diff --git a/komorebi/src/reconcile.rs b/komorebi/src/reconcile.rs index 37b517c8..0debc19f 100644 --- a/komorebi/src/reconcile.rs +++ b/komorebi/src/reconcile.rs @@ -1,14 +1,22 @@ //! Keeping komorebi's idea of where each window is in step with where the compositor has it, when -//! monitors come and go. +//! monitors come and go and when something else moves windows from one monitor to another. //! //! komorebi decides which monitor and workspace a window belongs to, and the compositor shows it -//! there. When an output is unplugged the compositor takes the windows on it to the ones that are -//! left. The window is then on one monitor and komorebi has it on another, and what komorebi has is -//! wrong: its tile is on a screen that is gone, and the window sits wherever it landed, untiled. +//! there. Two things move a window from under komorebi: the user (a shortcut that sends a window to +//! the next screen) and the compositor (when an output is unplugged it takes the windows on it to +//! the ones that are left; when it is plugged back it may take them back). Either way the window +//! is on one monitor and komorebi has it on another, and what komorebi has is wrong: its tile is +//! on a screen that is not the window's, and the window sits wherever it landed, untiled. //! -//! [`WindowManager::evacuate_monitor`] puts this right. It moves what was on a monitor that is gone -//! to the monitors that are left. The monitor reconciliator calls it for each monitor that went -//! ([`reconcile_display_change`]). +//! Three methods of [`WindowManager`] put this right: +//! +//! - [`WindowManager::follow_windows_to_their_monitors`] moves every container and floating window +//! to the monitor it is on. The monitor reconciliator calls it a moment after the monitors +//! change. +//! - [`WindowManager::follow_window_to_its_monitor`] does the same when the compositor reports that +//! a window moved to another output. +//! - [`WindowManager::evacuate_monitor`] moves what was on a monitor that is gone to the monitors +//! that are left. //! //! A window is on the monitor whose area has the middle of the window's frame. //! @@ -16,8 +24,10 @@ //! //! | Rule | Enforced by | Proof | //! |---|---|---| -//! | The windows of a monitor that is gone are tiled on the monitors that are left | [`WindowManager::evacuate_monitor`], called by the monitor reconciliator | `tests/hotplug.rs::the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left` | -//! | A stack moves as a stack | [`WindowManager::evacuate_monitor`] moves whole containers | `tests/hotplug.rs::a_stack_goes_with_the_monitor_as_a_stack` | +//! | A window moved to another output is tiled on that monitor, on the workspace of the same number | [`WindowManager::follow_windows_to_their_monitors`] | `tests/follow_output.rs::a_window_sent_to_another_screen_by_a_shortcut_is_tiled_there` | +//! | The windows of a monitor that is gone are tiled on the monitors that are left | [`WindowManager::evacuate_monitor`], called by the monitor reconciliator | `tests/follow_output.rs::the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left_and_come_back_with_it` | +//! | A stack moves as a stack | [`WindowManager::follow_windows_to_their_monitors`] and [`WindowManager::evacuate_monitor`] move whole containers | `tests/hotplug.rs::a_stack_goes_with_the_monitor_as_a_stack` | +//! | Nothing is moved while the user drags a window: the drag decides where that window goes | [`WindowManager::follow_windows_to_their_monitors`] returns at once | `tests/follow_output.rs::nothing_is_followed_while_the_user_drags_a_window` | //! | A monocle or a maximized window of a monitor that is gone is not lost | [`WindowManager::evacuate_monitor`] takes both out of the workspace and puts each in a container | `tests/hotplug.rs::a_monocle_of_a_monitor_that_goes_comes_back_as_a_container_with_the_windows_it_hid`, `tests/hotplug.rs::a_maximized_window_of_a_monitor_that_goes_is_not_lost` | //! //! [`reconcile_display_change`]: crate::monitor_reconciliator::reconcile_display_change @@ -50,6 +60,179 @@ impl WindowManager { .position(|monitor| contains(&monitor.size, point)) } + /// Returns true if and only if a move or a resize by the user is going on. + fn is_dragging(&self) -> bool { + self.pending_move_op.is_some() || self.pending_resize_op.is_some() + } + + /// Puts every container and floating window komorebi has on the monitor it is on, and returns + /// true if and only if anything moved. + /// + /// A container is on the monitor of its focused window. Whole containers move, to the workspace + /// of the same number, which is created if the monitor has fewer, at the end of its tiles. The + /// monitors that windows moved between are tiled again. Nothing moves while the user is + /// dragging a window. + /// + /// # Errors + /// + /// Returns an error if a monitor, workspace, container or window that was found misplaced is + /// not there to move, or if a monitor that changed cannot be tiled again. + /// + /// Covered by `tests/follow_output.rs::a_window_sent_to_another_screen_by_a_shortcut_is_tiled_there`, + /// `tests/follow_output.rs::nothing_is_followed_while_the_user_drags_a_window` and the tests of + /// [`WindowManager::follow_window_to_its_monitor`]. + pub fn follow_windows_to_their_monitors(&mut self) -> eyre::Result { + if self.is_dragging() { + return Ok(false); + } + + let mut touched: Vec = vec![]; + + // each pass moves one container or window, and the indexes of the rest shift with it + for _ in 0..10_000 { + let Some(misplaced) = self.find_misplaced() else { + break; + }; + + let Misplaced { + monitor: from_monitor, + workspace: workspace_idx, + slot, + to_monitor, + } = misplaced; + + tracing::info!( + "window(s) of workspace {workspace_idx} are on monitor {to_monitor}, not on {from_monitor}: following them" + ); + + let from = self + .monitors_mut() + .get_mut(from_monitor) + .and_then(|m| m.workspaces_mut().get_mut(workspace_idx)) + .ok_or_eyre("there is no such workspace")?; + + match slot { + Slot::Container(idx) => { + let container = from + .remove_container(idx) + .ok_or_eyre("there is no such container")?; + + let to = self + .monitors_mut() + .get_mut(to_monitor) + .ok_or_eyre("there is no such monitor")?; + to.ensure_workspace_count(workspace_idx + 1); + to.workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("there is no such workspace")? + .containers_mut() + .push_back(container); + } + Slot::Floating(id) => { + let window = from + .remove_floating_window(id) + .ok_or_eyre("there is no such window")?; + + let to = self + .monitors_mut() + .get_mut(to_monitor) + .ok_or_eyre("there is no such monitor")?; + to.ensure_workspace_count(workspace_idx + 1); + to.workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("there is no such workspace")? + .floating_windows_mut() + .push_back(window); + } + } + + for idx in [from_monitor, to_monitor] { + if !touched.contains(&idx) { + touched.push(idx); + } + } + } + + if touched.is_empty() { + return Ok(false); + } + + self.update_known_window_ids(); + + let offset = self.work_area_offset; + for idx in touched { + if let Some(monitor) = self.monitors_mut().get_mut(idx) { + monitor.update_focused_workspace(offset)?; + } + } + + Ok(true) + } + + /// Follows a window the compositor reports moved to another output, and returns true if and + /// only if anything moved. + /// + /// Every window is looked at, not only this one: when one has moved, others may have too. A + /// move komorebi made itself is already in its state, and finds nothing to do. + /// + /// # Errors + /// + /// The same as [`WindowManager::follow_windows_to_their_monitors`]. + /// + /// Covered by `tests/follow_output.rs::a_window_sent_to_another_screen_by_a_shortcut_is_tiled_there`. + pub fn follow_window_to_its_monitor(&mut self, window: Window) -> eyre::Result { + let moved = self.follow_windows_to_their_monitors()?; + if moved { + tracing::debug!("window {} was on another monitor", window.id); + } + Ok(moved) + } + + /// Returns the first container or floating window that is on another monitor than the one + /// komorebi has it on. + fn find_misplaced(&self) -> Option { + for (m_idx, monitor) in self.monitors().iter().enumerate() { + for (w_idx, workspace) in monitor.workspaces().iter().enumerate() { + for (c_idx, container) in workspace.containers().iter().enumerate() { + let Some(window) = container.focused_window() else { + continue; + }; + if let Some(to) = window + .rect() + .ok() + .and_then(|rect| self.monitor_idx_holding(&rect)) + .filter(|to| *to != m_idx) + { + return Some(Misplaced { + monitor: m_idx, + workspace: w_idx, + slot: Slot::Container(c_idx), + to_monitor: to, + }); + } + } + + for window in workspace.floating_windows() { + if let Some(to) = window + .rect() + .ok() + .and_then(|rect| self.monitor_idx_holding(&rect)) + .filter(|to| *to != m_idx) + { + return Some(Misplaced { + monitor: m_idx, + workspace: w_idx, + slot: Slot::Floating(window.id), + to_monitor: to, + }); + } + } + } + } + + None + } + /// Moves what was on the monitor with this serial number id to the monitors that are left, /// because the compositor has taken its windows there and they have nowhere else to be tiled. /// @@ -64,8 +247,9 @@ impl WindowManager { /// Returns an error if a monitor or workspace it moves windows between is not there, or if a /// maximized window cannot be put in a container. /// - /// Covered by `tests/hotplug.rs::a_stack_goes_with_the_monitor_as_a_stack` and - /// `tests/hotplug.rs::the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left`. + /// Covered by `tests/hotplug.rs::a_stack_goes_with_the_monitor_as_a_stack`, + /// `tests/hotplug.rs::the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left` and + /// `tests/follow_output.rs::the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left_and_come_back_with_it`. pub fn evacuate_monitor(&mut self, serial_number_id: &str) -> eyre::Result<()> { let Some(gone) = self .monitors() @@ -173,3 +357,23 @@ impl WindowManager { Ok(()) } } + +/// Where a misplaced container or window is in its workspace. +enum Slot { + /// A container, by its index in the workspace. + Container(usize), + /// A floating window, by its id. + Floating(u32), +} + +/// A container or floating window that is on another monitor than the one komorebi has it on. +struct Misplaced { + /// The index of the monitor komorebi has it on. + monitor: usize, + /// The index of its workspace, on both monitors. + workspace: usize, + /// Where it is in the workspace. + slot: Slot, + /// The index of the monitor it is on. + to_monitor: usize, +} diff --git a/komorebi/src/window_manager_event.rs b/komorebi/src/window_manager_event.rs index 12de9a8e..db970922 100644 --- a/komorebi/src/window_manager_event.rs +++ b/komorebi/src/window_manager_event.rs @@ -41,6 +41,8 @@ pub enum KWinNotification { WindowMinimized, /// `window.minimizedChanged` to false WindowRestored, + /// `window.outputChanged` + WindowOutputChanged, /// `window.captionChanged` CaptionChanged, /// `window.interactiveMoveResizeStarted` for a move @@ -75,6 +77,9 @@ pub enum WindowManagerEvent { Restore(SystemNotification, Window), /// The window's title changed TitleUpdate(SystemNotification, Window), + /// A window is on another output than it was: the user moved it (a shortcut), or KWin did when + /// an output went away or came back. Where it is now is what the window reports. + OutputChange(SystemNotification, Window), /// The user asked komorebi to manage the window (`ManageFocusedWindow`) Manage(Window), /// The user asked komorebi to stop managing the window (`UnmanageFocusedWindow`) @@ -111,6 +116,7 @@ impl WindowManagerEvent { | WindowManagerEvent::Minimize(_, window) | WindowManagerEvent::Restore(_, window) | WindowManagerEvent::TitleUpdate(_, window) + | WindowManagerEvent::OutputChange(_, window) | WindowManagerEvent::Manage(window) | WindowManagerEvent::Unmanage(window) | WindowManagerEvent::MoveStart(_, window) @@ -140,6 +146,7 @@ impl WindowManagerEvent { | WindowManagerEvent::Minimize(n, _) | WindowManagerEvent::Restore(n, _) | WindowManagerEvent::TitleUpdate(n, _) + | WindowManagerEvent::OutputChange(n, _) | WindowManagerEvent::MoveStart(n, _) | WindowManagerEvent::MoveEnd(n, _) | WindowManagerEvent::ResizeStart(n, _) @@ -175,6 +182,18 @@ mod tests { assert_eq!(event.to_string(), "Minimize"); } + #[test] + fn an_output_change_names_its_window_and_the_signal_it_came_from() { + let event = WindowManagerEvent::OutputChange( + SystemNotification::KWin(KWinNotification::WindowOutputChanged), + Window::from(302), + ); + + assert_eq!(event.window(), Some(Window::from(302))); + assert_eq!(event.notification(), "WindowOutputChanged"); + assert_eq!(event.to_string(), "OutputChange"); + } + #[test] fn a_workspace_change_is_about_a_monitor_and_no_window() { let event = WindowManagerEvent::WorkspaceChange( diff --git a/komorebi/tests/follow_output.rs b/komorebi/tests/follow_output.rs new file mode 100644 index 00000000..d8ac89aa --- /dev/null +++ b/komorebi/tests/follow_output.rs @@ -0,0 +1,238 @@ +//! Windows that something else moves to another output, against an in-memory desktop. +//! +//! A window moved to another output by a shortcut is on that monitor's workspace from then on. The +//! same holds for the windows the compositor takes back to a monitor that is plugged in again. +//! +//! 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::reconcile_display_change; +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), + ) +} + +fn moved_to_another_output(id: u32) -> WindowManagerEvent { + WindowManagerEvent::OutputChange( + SystemNotification::KWin(KWinNotification::WindowOutputChanged), + 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 the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left_and_come_back_with_it() { + let (fake, mut wm) = desktop_of(&["Test-A", "Test-B"]); + + for id in [50_001, 50_002, 50_003] { + open_on(&fake, &mut wm, id, 0); + } + for id in [50_004, 50_005] { + open_on(&fake, &mut wm, id, 1920); + } + assert_eq!(wm.monitors().len(), 2); + assert_eq!(containers_on(&wm, 0), 3); + assert_eq!(containers_on(&wm, 1), 2); + + // the second monitor is unplugged, and the compositor takes its windows to the first + fake.remove_monitor(51); + for (id, at) in [(50_004, 300), (50_005, 340)] { + fake.move_window_by_user(id, somewhere_in(at - 200)); + } + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!( + wm.monitors().len(), + 1, + "the monitor that went is still there" + ); + assert_eq!(containers_on(&wm, 0), 5, "its windows were not taken over"); + for id in 50_001..=50_005 { + assert_eq!( + wm.known_window_ids.get(&id).map(|(m, _)| *m), + Some(0), + "window {id} is not managed on the monitor that is left" + ); + } + assert_tiled_in(&fake, &[50_001, 50_002, 50_003, 50_004, 50_005], &area(0)); + + // it is plugged back in + fake.add_monitor(51, "Test-B", area(1920), work_area(1920)); + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!(wm.monitors().len(), 2, "the monitor did not come back"); + assert_eq!( + containers_on(&wm, 1), + 0, + "it came back with windows that were tiled elsewhere" + ); + + // and the compositor, a moment later, takes two windows back to it + for id in [50_004, 50_005] { + fake.move_window_by_user(id, somewhere_in(1920)); + with_backend(fake.clone(), || { + wm.process_event(moved_to_another_output(id)) + }) + .unwrap(); + } + + assert_eq!(containers_on(&wm, 0), 3); + assert_eq!(containers_on(&wm, 1), 2); + assert_tiled_in(&fake, &[50_001, 50_002, 50_003], &work_area(0)); + assert_tiled_in(&fake, &[50_004, 50_005], &work_area(1920)); +} + +#[test] +fn a_window_sent_to_another_screen_by_a_shortcut_is_tiled_there() { + let (fake, mut wm) = desktop_of(&["Test-E", "Test-F"]); + for id in [50_201, 50_202, 50_203] { + open_on(&fake, &mut wm, id, 0); + } + + // the user sends a window to the next screen, and the compositor puts it there + fake.move_window_by_user(50_202, somewhere_in(1920)); + with_backend(fake.clone(), || { + wm.process_event(moved_to_another_output(50_202)) + }) + .unwrap(); + + assert_eq!(containers_on(&wm, 0), 2); + assert_eq!(containers_on(&wm, 1), 1); + assert_eq!(wm.known_window_ids.get(&50_202).map(|(m, _)| *m), Some(1)); + assert_tiled_in(&fake, &[50_201, 50_203], &work_area(0)); + assert_tiled_in(&fake, &[50_202], &work_area(1920)); +} + +#[test] +fn nothing_is_followed_while_the_user_drags_a_window() { + let (fake, mut wm) = desktop_of(&["Test-I", "Test-J"]); + for id in [50_401, 50_402, 50_403] { + open_on(&fake, &mut wm, id, 0); + } + + // the window is over the other monitor, and the user has not let go of it yet + fake.move_window_by_user(50_402, somewhere_in(1920)); + wm.pending_move_op = Arc::new(Some((0, 0, 50_402))); + let moved = with_backend(fake.clone(), || wm.follow_windows_to_their_monitors()).unwrap(); + assert!(!moved, "a window was followed during a move"); + assert_eq!(containers_on(&wm, 0), 3); + assert_eq!(containers_on(&wm, 1), 0); + + // the same for the edge of a window + wm.pending_move_op = Arc::new(None); + wm.pending_resize_op = Arc::new(Some((50_401, None))); + let moved = with_backend(fake.clone(), || wm.follow_windows_to_their_monitors()).unwrap(); + assert!(!moved, "a window was followed during a resize"); + assert_eq!(containers_on(&wm, 0), 3); + + // and once they have let go, it is + wm.pending_resize_op = Arc::new(None); + let moved = with_backend(fake.clone(), || wm.follow_windows_to_their_monitors()).unwrap(); + assert!(moved, "the window was not followed after the drag"); + assert_eq!(containers_on(&wm, 0), 2); + assert_eq!(containers_on(&wm, 1), 1); + assert_eq!(wm.known_window_ids.get(&50_402).map(|(m, _)| *m), Some(1)); +} diff --git a/nix/tests/vm/suites/hotplug/test.py b/nix/tests/vm/suites/hotplug/test.py index c55529c7..d546c768 100644 --- a/nix/tests/vm/suites/hotplug/test.py +++ b/nix/tests/vm/suites/hotplug/test.py @@ -1,11 +1,15 @@ # 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, and the windows on it are tiled in the size it has now; that an output that is -# disabled (KWin's way to unplug one) takes its windows to the other output, where komorebi tiles -# them; and that the output enabled again is a monitor again, which windows can be sent to. -# `komorebi/tests/monitor_resize.rs` and `komorebi/tests/hotplug.rs` check the same against the -# fake. -from komorebi_vm import Checks, Kde, Rect +# monitor's size, and the windows on it are tiled in the size it has now; that a window moved to +# the other output by something other than komorebi (what a shortcut that sends a window to the +# next screen does) is tiled on that monitor, and one komorebi moved itself is left where it is; +# that an output that is disabled (KWin's way to unplug one) takes its windows to the other +# output, where komorebi tiles them; and that the output enabled again is a monitor again, which +# windows can be sent to. `komorebi/tests/monitor_resize.rs`, `komorebi/tests/hotplug.rs` and +# `komorebi/tests/follow_output.rs` check the same against the fake. +import time + +from komorebi_vm import Checks, Kde, Rect, daemon W, H = 1920, 1080 @@ -58,6 +62,35 @@ def on_the_right() -> list[str]: return [t for t, r in zip(TITLES, rects(TITLES), strict=True) if r.x >= W] +def output_changes() -> int: + """How many times KWin has told the daemon that a window is on another output.""" + return sum(1 for e in daemon.events(kde.komorebi_log()) if e.kind == "OutputChange") + + +def output_lines() -> list[str]: + """The lines of the daemon's log about windows on other outputs, for a failure to be read.""" + log = kde.komorebi_log().splitlines() + return [line[-160:] for line in log if "OutputChange" in line or "following them" in line][-8:] + + +def let_komorebi_finish() -> None: + """Waits until the bridge has stopped asking again for the position of the windows komorebi + just moved. + + The bridge asks again for a second after each command (`WANT_MS` in `bridge.js`), which puts + back a window that somebody moves in that time. A user moves a window later than that, so a + test that plays the user does too. + """ + time.sleep(2 * SCALE) + + +def show(what: str) -> None: + """Prints where komorebi has the windows and where KWin has them, for a failure to be read.""" + state = f"{containers_on(0)} on the left monitor, {containers_on(1)} on the right" + frames = ", ".join(f"{t} {r}" for t, r in zip(TITLES, rects(TITLES), strict=True)) + print(f" {what}: komorebi has {state}; KWin has {frames}") + + 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") @@ -100,10 +133,31 @@ checks.check( kde.wait_for(lambda: monitor(0) == Rect(0, 0, W, H) and tiled_in(TITLES, monitor(0)), 20), ) -print("== 3. two windows go to the right monitor") -kde.komorebic("move-to-monitor", "1") -kde.komorebic("focus-monitor", "0") +print("== 3. komorebi sends a window to the right monitor itself") +changes = output_changes() kde.komorebic("move-to-monitor", "1") +checks.check( + "komorebi has two containers on the left monitor and one on the right", + kde.wait_for(lambda: containers_on(0) == 2 and containers_on(1) == 1), +) +checks.check( + "KWin reported the window on the other output", + kde.wait_for(lambda: output_changes() > changes), +) +moved = on_the_right() +checks.check( + "the window is tiled on the right output", + len(moved) == 1 and tiled_in(moved, monitor(1)), +) +checks.check( + "and it is still on the right monitor once KWin has said so", + containers_on(0) == 2 and containers_on(1) == 1, +) + +print("== 4. the user sends another window to the right screen") +sent = [t for t in TITLES if t not in moved][0] +let_komorebi_finish() +kde.sh(f"kdotool windowmove {kde.wid(sent)} 2100 300") checks.check( "komorebi has one container on the left monitor and two on the right", kde.wait_for(lambda: containers_on(0) == 1 and containers_on(1) == 2), @@ -116,8 +170,10 @@ checks.check( and tiled_in([t for t in TITLES if t not in on_the_right()], monitor(0)) ), ) +show("after the user's move") +print(" " + "\n ".join(output_lines())) -print("== 4. the right output is unplugged") +print("== 5. the right output is unplugged") kde.outputs.disable(right) checks.check("komorebi has one monitor", kde.wait_for(lambda: monitors() == 1, timeout=30)) checks.check("and all three windows on it", kde.wait_for(lambda: containers_on(0) == 3)) @@ -134,7 +190,7 @@ checks.check( ), ) -print("== 5. it is plugged back") +print("== 6. it is plugged back") kde.outputs.enable(right) checks.check("komorebi has two monitors again", kde.wait_for(lambda: monitors() == 2, timeout=30)) checks.check("and still three windows", kde.wait_for(lambda: kde.managed_all() == 3)) @@ -147,19 +203,25 @@ checks.check( ), ) -print("== 6. and a window can be sent over again") -kde.komorebic("focus-monitor", "0") -kde.komorebic("move-to-monitor", "1") +print("== 7. and a window can be sent over again") +let_komorebi_finish() +kde.sh(f"kdotool windowmove {kde.wid('w3')} 2100 300") checks.check( - "komorebi has the window on the right monitor", - kde.wait_for(lambda: containers_on(1) >= 1), + "komorebi follows it: one container on the right monitor", + kde.wait_for(lambda: containers_on(1) == 1), ) checks.check( - "it is tiled on the right output", - kde.wait_for(lambda: len(on_the_right()) >= 1 and tiled_in(on_the_right(), monitor(1))), + "it is tiled on the right output, and the others together on the left", + kde.wait_for( + lambda: on_the_right() == ["w3"] + and tiled_in(["w3"], monitor(1)) + and tiled_in(["w1", "w2"], monitor(0)) + ), ) +show("after the second move") +print(" " + "\n ".join(output_lines())) -print("== 7. nothing is left behind") +print("== 8. nothing is left behind") checks.check( "komorebi is running and answers", kde.komorebi_running() and kde.run("komorebic state")[0] == 0,