From f530318ceeb37bbc138f6ea618d5a39c093cdc0d Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 20:21:53 -0700 Subject: [PATCH] feat(wm): outputs unplugged and plugged back KWin takes the windows of an output that goes to the outputs that are left and puts them where it likes, so komorebi's tiles for them were on a screen that was gone and the windows sat untiled where they landed. The monitor reconciliator now deals with a change in the set of monitors: the table announces it as a display connection change instead of a resize, and reconcile_display_change reads the monitors again, keeps each one that went (workspaces, layouts, paddings, offsets) in a cache, has the window manager move what was on it to the monitors that are left, and removes it. The move is WindowManager::evacuate_monitor, in the new reconcile module: each container and floating window goes to the workspace of the same number on the monitor that holds the middle of its window, or on the first monitor that is left when it is on none, and the workspaces that changed are tiled. A stack goes as one container, and a monocle and a maximized window come as ordinary containers. A monitor that is plugged in takes what was cached under its serial number id, less the windows that closed or that the window manager has elsewhere by now, so it comes back with its settings and none of the windows it had. A monitor komorebi never saw gets workspaces of its own, and when the compositor reports no output at all nothing is done. The workspace rules of a monitor that goes are dropped, and those of the workspaces of one that comes back apply again. komorebi.json fills the cache as well: a monitor that has the entry its display index preference names is kept with it, and so is the entry of each preference whose monitor is not connected, which that monitor takes when it is plugged in. vm-hotplug now sends two windows to the other monitor with komorebic, disables that output, enables it again and sends a window over once more. --- README.md | 15 +- docs/dev/ARCHITECTURE.md | 55 ++- docs/dev/DESIGN.md | 11 + docs/dev/PLATFORM.md | 4 + docs/index.md | 2 +- docs/usage/changing-monitors.md | 39 +- docs/usage/subscribing-to-notifications.md | 7 +- komorebi/src/backend/fake.rs | 7 + komorebi/src/kwin/state.rs | 78 +++- komorebi/src/lib.rs | 5 +- komorebi/src/monitor_reconciliator.rs | 394 ++++++++++++++++++++- komorebi/src/reconcile.rs | 175 +++++++++ komorebi/src/static_config.rs | 110 ++++++ komorebi/tests/hotplug.rs | 346 ++++++++++++++++++ nix/tests/vm/suites/hotplug/test.py | 122 +++++-- 15 files changed, 1291 insertions(+), 79 deletions(-) create mode 100644 komorebi/src/reconcile.rs create mode 100644 komorebi/tests/hotplug.rs diff --git a/README.md b/README.md index 36a085ce..66e9f139 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,11 @@ without a checkout of the toolchain. some elements are locked in place. It answers the messages on the daemon's socket (`core::SocketMessage`, `process_command`), shows its state (`state`), keeps the monitors in step with the compositor's outputs as they change size - (`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`). + and are unplugged and plugged in (`monitor_reconciliator`, `reconcile`), + sends its subscribers a notification of each event, message and change of + the monitors it handles (`notify_subscribers`), and is configured by a + `komorebi.json` that it reads and reads again when it changes + (`static_config`). - [`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. @@ -168,7 +169,11 @@ 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. +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. 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 8815945d..d2cd203b 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -335,6 +335,32 @@ reads the size and the work area of every monitor from the backend again it has now, and notifies the subscribers ([`NotificationEvent::Monitor`]). Another monitor's windows keep their tiles until something tiles them again, such as `Retile`. +A monitor that is unplugged or plugged in changes the set of outputs, and the table announces +that as a [`MonitorNotification::DisplayConnectionChange`], by the ids of the monitors. KWin has +already taken the windows of the output that went to the outputs that are left, so komorebi's +tiles for them are on a screen that is gone and the windows sit untiled where they landed. The +reconciliator ([`reconcile_display_change`]) reads the monitors from the backend and compares +them with the window manager's by their serial number ids. For each monitor that went it keeps a +copy, with its workspaces and settings, in a cache that lives as long as the daemon +([`insert_in_monitor_cache`]), and has the window manager move what was on it to the monitors +that are left ([`WindowManager::evacuate_monitor`]). Each container and floating window goes to +the workspace of the same number on the monitor that now holds the middle of its window's frame, +or on the first monitor that is left when the window is on none; a stack goes as the container it +is, and a monocle and a maximized window come as ordinary containers. The monitor is then removed. +A monitor that is new to the window manager is added by [`load_monitor_information`], and when +the cache has a monitor with its serial number id, the new monitor takes the cached workspaces and +settings, less the windows that have closed or that the window manager has elsewhere now, and the +cache entry is used once. Every monitor then shows the workspace in front again. The +focused monitor and workspace are focused again, since adding a monitor can take the focus. When +KWin reports no output at all, nothing is done. + +A monitor that goes takes its workspace rules (the entries of [`WORKSPACE_MATCHING_RULES`] that +name its index) out of the list, and those of the workspaces a cached monitor brings back are +added again. The configuration fills the cache as well: [`StaticConfig::postload`] and +[`StaticConfig::reload`] keep a copy of each monitor that has the entry its display index +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 command socket The daemon takes messages on a Unix socket, `komorebi.sock` in [`DATA_DIR`] @@ -1079,8 +1105,11 @@ module's unit. 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. + backend sends [`MonitorNotification`]s on, [`listen_for_notifications`], the thread that + takes them, and [`reconcile_display_change`], which brings the window manager's monitors in + line with the outputs; + [`reconcile`](../../komorebi/src/reconcile.rs): moves what was on a monitor that went to the + monitors that are left. - [`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 @@ -1245,8 +1274,15 @@ 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 | +| 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 | +| 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 | +| A monitor that is plugged in again has the settings it had, and none of the windows it had | framework path: [`reconcile_display_change`] keeps the monitor in a cache when it goes, and applies it, less the windows the window manager has elsewhere, when a monitor with its serial number id comes | `a_monitor_that_comes_back_gets_what_was_kept_of_it_and_none_of_the_windows_it_had` | none | +| A monitor komorebi has not seen gets workspaces of its own | framework path: [`load_monitor_information`] adds the monitors the window manager does not have | `a_monitor_that_was_never_there_gets_windows_of_its_own` | none | +| When KWin reports no output, the window manager keeps the monitors it has | framework path: [`reconcile_display_change`] returns before it removes any | `a_desktop_that_reports_no_monitor_is_left_as_it_was` | none | +| A monitor is only removed once its windows are on the monitors that are left | framework path: [`reconcile_display_change`] evacuates every monitor that went before it removes any | `the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left` | none | | 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 | @@ -1472,9 +1508,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): 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/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; + [`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 fake's desktops; [`komorebi/tests/workspace_commands.rs`](../../komorebi/tests/workspace_commands.rs), naming, @@ -1709,6 +1747,11 @@ A window is shown, and ends up on a tile: [`MonitorNotification::Resize`]: ../../komorebi/src/monitor_reconciliator.rs [`listen_for_notifications`]: ../../komorebi/src/monitor_reconciliator.rs [`update_monitor_work_areas`]: ../../komorebi/src/desktop.rs +[`load_monitor_information`]: ../../komorebi/src/desktop.rs +[`MonitorNotification::DisplayConnectionChange`]: ../../komorebi/src/monitor_reconciliator.rs +[`reconcile_display_change`]: ../../komorebi/src/monitor_reconciliator.rs +[`insert_in_monitor_cache`]: ../../komorebi/src/monitor_reconciliator.rs +[`WindowManager::evacuate_monitor`]: ../../komorebi/src/reconcile.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 bf9329b8..16e4a77d 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -600,3 +600,14 @@ signal is not the final one. The bridge keeps the outputs it last sent and sends 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. + +## Why does komorebi take the windows of an unplugged monitor over itself? + +Because nothing else can tile them. When an output goes, KWin takes its windows to the outputs +that are left and puts them where it likes. komorebi has them in the containers of the monitor +that went, whose tiles are on a screen that is gone, so the windows sit untiled wherever KWin +put them. komorebi moves each container to the monitor that now holds the middle of its window, +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. diff --git a/docs/dev/PLATFORM.md b/docs/dev/PLATFORM.md index 6893329c..37b30fa5 100644 --- a/docs/dev/PLATFORM.md +++ b/docs/dev/PLATFORM.md @@ -438,6 +438,10 @@ with two outputs of 1920x1080: 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. +- `kscreen-doctor output..disable` is how a headless KWin loses an output, and `enable` + 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. ## How long a retile takes diff --git a/docs/index.md b/docs/index.md index 3584fc0b..dce76995 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,7 +68,7 @@ to the window under the pointer, [Themes](usage/themes.md) how a theme gives the colors, and [Wallpapers](usage/wallpapers.md) how a monitor or a workspace shows a picture of its own, and what it does to the borders. [Changing monitors](usage/changing-monitors.md) explains what komorebi does when a monitor -changes size. +changes size, is unplugged or is plugged in. [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 index b4740920..10b0c42f 100644 --- a/docs/usage/changing-monitors.md +++ b/docs/usage/changing-monitors.md @@ -18,12 +18,41 @@ 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 monitor that is unplugged or plugged in is not followed: komorebi keeps the monitors it -started with until it is started again. +## A monitor is unplugged + +KWin takes the windows of an output that goes (it is unplugged, or disabled with +`kscreen-doctor output..disable`) to the outputs that are left, and puts them where it +likes. komorebi takes the monitor's workspaces over there: every container, and every floating +window, goes to the workspace with the same number on the monitor KWin put its window on, and +the workspaces that changed are tiled again. A [stack](stacking-windows.md) goes as a stack. A +monocle window and a maximized window come as tiles of their own, and the windows the monocle hid +are shown again. + +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. + +## A monitor is plugged in + +komorebi knows a monitor by the manufacturer, the model and the serial number KWin reports for +the output, or by the output's name when it has no serial number. A monitor komorebi has kept +comes back with what was kept: its workspaces and their settings, and the wallpaper of the +monitor, less the windows that have closed or that are somewhere else now. Its windows were tiled +on other monitors when it went, so it comes back with none of them. The monitor that had the focus +keeps it, and every monitor shows its workspace in front. + +A monitor that komorebi has not seen gets workspaces of its own, and the windows that open on it +are tiled there. When `komorebi.json` has an entry for the monitor in +[`display_index_preferences`](configuration.md#monitors-and-workspaces) and the monitor was not +connected when the file was read, it takes the entry when it is plugged in. The monitors are put +at the index the user gave them with +[`komorebic display-index-preference`](../cli/display-index-preference.md), and after the other +monitors otherwise. ## 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. +dealt with a change of the monitors, whether or not anything changed in the state. Its `event` is +`{"Resize":0}` for a change of size, where the number is the id of the monitor that changed, or 0 +when KWin does not say which, and `{"DisplayConnectionChange":0}` for a monitor that was +unplugged or plugged in. diff --git a/docs/usage/subscribing-to-notifications.md b/docs/usage/subscribing-to-notifications.md index 6c70be28..4cc04a6f 100644 --- a/docs/usage/subscribing-to-notifications.md +++ b/docs/usage/subscribing-to-notifications.md @@ -38,9 +38,10 @@ A notification has two fields: 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"}`. 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. + `{"type":"Retile"}`. A change of the monitors is the name of the change with its content: + `{"Resize":0}` for a monitor that changed size, with the id of the monitor, or 0 when KWin + does not say which, and `{"DisplayConnectionChange":0}` for a monitor that was unplugged or + plugged in. [Changing monitors](changing-monitors.md) says when each is sent. - `state`: the state of the window manager after it, with the same fields as what [`komorebic state`](../cli/state.md) prints, on one line. diff --git a/komorebi/src/backend/fake.rs b/komorebi/src/backend/fake.rs index 1ceee1b7..982a1b1b 100644 --- a/komorebi/src/backend/fake.rs +++ b/komorebi/src/backend/fake.rs @@ -233,6 +233,13 @@ impl FakeBackend { fake } + /// Unplugs a monitor. The compositor takes its windows to the others by itself: a test moves + /// them (`move_window_by_user`) as it sees fit. Nothing is done when there is no monitor with + /// this id. + pub fn remove_monitor(&self, id: u32) { + self.state.lock().monitors.retain(|m| m.id != id); + } + /// 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) { diff --git a/komorebi/src/kwin/state.rs b/komorebi/src/kwin/state.rs index d3997be2..d2cb862a 100644 --- a/komorebi/src/kwin/state.rs +++ b/komorebi/src/kwin/state.rs @@ -698,8 +698,9 @@ impl Table { /// 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`]). + /// was last sent. A monitor that came or went is a + /// [`MonitorNotification::DisplayConnectionChange`], and one whose size or work area changed + /// 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. /// @@ -821,8 +822,10 @@ impl Table { } /// 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. + /// table had, the change is announced: as a [`MonitorNotification::DisplayConnectionChange`] + /// when the monitors are not the same (a monitor came or went), and otherwise as a + /// [`MonitorNotification::Resize`] of the first monitor whose size or work area is not what it + /// was. fn set_monitors(&mut self, screens: &Value, announce: bool) -> Vec { let monitors: Vec = screens .as_array() @@ -834,13 +837,25 @@ impl Table { 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))] + let ids = |m: &[MonitorInfo]| { + let mut ids: Vec = m.iter().map(|m| m.id).collect(); + ids.sort_unstable(); + ids + }; + + if ids(&previous) != ids(&self.monitors) { + vec![Output::Monitor( + MonitorNotification::DisplayConnectionChange(0), + )] + } else { + // 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 { @@ -2190,7 +2205,7 @@ mod tests { } #[test] - fn a_new_panel_is_a_resize_and_the_same_screens_again_are_nothing() { + fn plugging_in_a_monitor_is_a_connection_change_and_a_new_panel_is_a_resize() { let mut t = table(vec![]); let now = Instant::now(); let mut screens = snapshot(vec![])["screens"].clone(); @@ -2210,20 +2225,45 @@ mod tests { // 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); + let out = t.handle( + &json!({ "type": "screens", "screens": screens.clone() }), + now, + ); assert!(matches!( &out[..], [Output::Monitor(MonitorNotification::Resize(_))] )); assert_eq!(t.monitors()[0].size.right, 1280); + // a second monitor appears + let mut two = screens.clone(); + two.as_array_mut().unwrap().push(json!({ + "name": "DP-2", "manufacturer": "ACME", "model": "M1", "serial": "S2", + "geo": { "x": 1920, "y": 0, "w": 1920, "h": 1080 }, "area": { "x": 1920, "y": 0, "w": 1920, "h": 1080 }, + })); + let two_screens = two.clone(); + let out = t.handle(&json!({ "type": "screens", "screens": two }), now); + assert!(matches!( + &out[..], + [Output::Monitor( + MonitorNotification::DisplayConnectionChange(_) + )] + )); + assert_eq!(t.monitors().len(), 2); + // 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()); + let again = json!({ "type": "screens", "screens": two_screens }); + assert!(t.handle(&again, now).is_empty()); + + // and one goes + let out = t.handle(&json!({ "type": "screens", "screens": screens }), now); + assert!(matches!( + &out[..], + [Output::Monitor( + MonitorNotification::DisplayConnectionChange(_) + )] + )); + assert_eq!(t.monitors().len(), 1); } #[test] diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 9427e57f..33473fd6 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -19,7 +19,9 @@ //! ([`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. +//! 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. //! //! 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 @@ -75,6 +77,7 @@ pub mod monitor; pub mod monitor_reconciliator; pub mod process_command; pub mod process_event; +pub mod reconcile; pub mod state; pub mod static_config; pub mod theme_manager; diff --git a/komorebi/src/monitor_reconciliator.rs b/komorebi/src/monitor_reconciliator.rs index fe714baf..3b73c27f 100644 --- a/komorebi/src/monitor_reconciliator.rs +++ b/komorebi/src/monitor_reconciliator.rs @@ -1,23 +1,35 @@ -//! Keeping komorebi's monitors in step with the compositor's outputs, as they change size. +//! Keeping komorebi's monitors in step with the compositor's outputs, as they change size and are +//! unplugged and plugged back. //! -//! 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. +//! The KWin backend sends a [`MonitorNotification`] when KWin's list of outputs changes. The +//! thread [`listen_for_notifications`] starts takes them one at a time: +//! +//! - [`MonitorNotification::Resize`]: the size and work area of every monitor are read again +//! ([`desktop::update_monitor_work_areas`]), and the focused workspace of the focused monitor +//! is tiled in the area it has now. +//! - [`MonitorNotification::DisplayConnectionChange`]: [`reconcile_display_change`] removes the +//! monitors that went, once what was on them is on the monitors that are left, and adds the ones +//! that came. +//! +//! 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. //! //! 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. +//! Covered by `tests/monitor_resize.rs` and `tests/hotplug.rs` against the fake backend, and by +//! the `vm-hotplug` VM suite. +use crate::DISPLAY_INDEX_PREFERENCES; use crate::Notification; use crate::NotificationEvent; +use crate::WORKSPACE_MATCHING_RULES; use crate::border_manager; +use crate::core::config_generation::WorkspaceMatchingRule; use crate::desktop; +use crate::monitor::Monitor; use crate::notify_subscribers; use crate::state::State; use crate::window_manager::WindowManager; @@ -26,25 +38,30 @@ use crossbeam_channel::Sender; use parking_lot::Mutex; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; 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. +/// The number in `Resize` and `DisplayConnectionChange` 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), + /// A monitor was connected or disconnected: the KWin backend sends 0 + DisplayConnectionChange(u32), } static CHANNEL: OnceLock<(Sender, Receiver)> = OnceLock::new(); +static MONITOR_CACHE: OnceLock>> = 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) { @@ -59,6 +76,27 @@ fn event_rx() -> Receiver { channel().1.clone() } +/// Keeps `monitor`, with its workspaces and settings, for when a monitor with the same serial +/// number id is plugged in again. +/// +/// It is kept under `serial_number_id`, except when a display index preference has that id as its +/// value: then it is kept under the monitor's own serial number id. +pub fn insert_in_monitor_cache(serial_number_id: &str, monitor: Monitor) { + let display_index_preferences = DISPLAY_INDEX_PREFERENCES.read(); + let mut dip_ids = display_index_preferences.values(); + let preferred_id = if dip_ids.any(|id| id == serial_number_id) { + monitor.serial_number_id.clone() + } else { + serial_number_id.to_string() + }; + + let mut monitor_cache = MONITOR_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock(); + + monitor_cache.insert(preferred_id, monitor); +} + /// 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) { @@ -96,6 +134,328 @@ pub fn listen_for_notifications(wm: Arc>) -> color_eyre::Re Ok(()) } +/// Brings komorebi's monitors in line with the compositor's: removes the ones that went and adds +/// the ones that came. +/// +/// The containers and windows of a monitor that went are moved with +/// [`WindowManager::evacuate_monitor`] to the monitor the compositor took each window to, and the +/// monitor itself is kept for when it comes back. A monitor that comes back gets what was kept of +/// it, less the windows that closed or that are now somewhere else, and the workspace rules of +/// its workspaces (`WORKSPACE_MATCHING_RULES`) apply again; those of a monitor that went are +/// dropped. When the number of monitors has not changed, only the id, size and work area of each +/// are read again; when the compositor reports no monitor at all, nothing is done. +/// +/// # Errors +/// +/// Returns an error if focusing a monitor or workspace, or showing and laying out the workspaces +/// of the monitors, fails. A monitor list that cannot be read is taken as no change, and a +/// monitor whose windows cannot be moved is logged. +pub fn reconcile_display_change(wm: &mut WindowManager) -> color_eyre::Result<()> { + let attached_devices = desktop::latest_monitor_information().unwrap_or_default(); + + let mut monitor_cache = MONITOR_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock(); + + let initial_monitor_count = wm.monitors().len(); + + // Make sure that in our state any attached displays have the latest data from the desktop + for monitor in wm.monitors_mut() { + for attached in &attached_devices { + let serial_number_ids_match = attached.serial_number_id == monitor.serial_number_id; + + if serial_number_ids_match { + monitor.id = attached.id; + monitor.device = attached.device.clone(); + monitor.serial_number_id = attached.serial_number_id.clone(); + monitor.size = attached.size; + monitor.work_area_size = attached.work_area_size; + } + } + } + + if initial_monitor_count == attached_devices.len() { + tracing::debug!("monitor counts match, reconciliation not required"); + return Ok(()); + } + + if attached_devices.is_empty() { + tracing::debug!("no devices found, skipping reconciliation to avoid breaking state"); + return Ok(()); + } + + if initial_monitor_count > attached_devices.len() { + tracing::info!( + "monitor count mismatch ({initial_monitor_count} vs {}), removing disconnected monitors", + attached_devices.len() + ); + + // Collect the ids in our state which aren't in the current attached display ids + // These are monitors that have been removed + let mut newly_removed_displays = vec![]; + + for (m_idx, m) in wm.monitors().iter().enumerate() { + if !attached_devices + .iter() + .any(|attached| attached.serial_number_id.eq(&m.serial_number_id)) + { + let id = m.serial_number_id.clone(); + + newly_removed_displays.push(id.clone()); + + // Remove any workspace_rules for this specific monitor + let mut workspace_rules = WORKSPACE_MATCHING_RULES.lock(); + let mut rules_to_remove = Vec::new(); + for (i, rule) in workspace_rules.iter().enumerate().rev() { + if rule.monitor_index == m_idx { + rules_to_remove.push(i); + } + } + + for i in rules_to_remove { + workspace_rules.remove(i); + } + + // Let's add their state to the cache for later, make sure to use what + // the user set as preference as the id. + let display_index_preferences = DISPLAY_INDEX_PREFERENCES.read(); + let mut dip_ids = display_index_preferences.values(); + + let preferred_id = if dip_ids.any(|id| m.serial_number_id.eq(id)) { + m.serial_number_id.clone() + } else { + id.to_string() + }; + + monitor_cache.insert(preferred_id, m.clone()); + } + } + + // What was on the monitors that went is now on the ones that are left, because + // that is where the compositor took the windows: it is theirs to tile + for id in &newly_removed_displays { + if let Err(error) = wm.evacuate_monitor(id) { + tracing::error!("could not move the windows of {id}: {error}"); + } + } + wm.update_known_window_ids(); + + if !newly_removed_displays.is_empty() { + // After we have cached them, remove them from our state + wm.monitors_mut().retain(|m| { + !newly_removed_displays + .iter() + .any(|id| m.serial_number_id.eq(id)) + }); + } + + let post_removal_monitor_count = wm.monitors().len(); + let focused_monitor_idx = wm.focused_monitor_idx(); + if focused_monitor_idx >= post_removal_monitor_count { + wm.focus_monitor(0)?; + } + + let offset = wm.work_area_offset; + + for monitor in wm.monitors_mut() { + // If we have lost a monitor, update everything to filter out any jank + if initial_monitor_count != post_removal_monitor_count { + monitor.update_focused_workspace(offset)?; + } + } + } + + let post_removal_monitor_count = wm.monitors().len(); + + // This is the list of device ids after we have removed detached displays. We can + // keep this with just the device_ids without the serial numbers since this is used + // only to check which one is the newly added monitor below if there is a new + // monitor. Everything done after with said new monitor will again consider both + // serial number and device ids. + let post_removal_serial_ids = wm + .monitors() + .iter() + .map(|m| &m.serial_number_id) + .cloned() + .collect::>(); + + // Check for and add any new monitors that may have been plugged in + // Monitor and display index preferences get applied in this function + if let Err(error) = desktop::load_monitor_information(wm) { + tracing::error!("failed to update load monitor information: {error}"); + } + + let post_addition_monitor_count = wm.monitors().len(); + + if post_addition_monitor_count > post_removal_monitor_count { + tracing::info!( + "monitor count mismatch ({post_removal_monitor_count} vs {post_addition_monitor_count}), adding connected monitors", + ); + + let known_window_ids = wm.known_window_ids.clone(); + let offset = wm.work_area_offset; + let mouse_follows_focus = wm.mouse_follows_focus; + let focused_monitor_idx = wm.focused_monitor_idx(); + let focused_workspace_idx = wm.focused_workspace_idx()?; + + // Look in the updated state for new monitors + for (i, m) in wm.monitors_mut().iter_mut().enumerate() { + let serial_id = m.serial_number_id.clone(); + // We identify a new monitor when we encounter a new device id + if !post_removal_serial_ids.contains(&serial_id) { + let mut cache_hit = false; + let mut cached_id = String::new(); + // Check if that device id exists in the cache for this session + if let Some((id, cached)) = monitor_cache.get_key_value(&serial_id) { + cache_hit = true; + cached_id = id.clone(); + + tracing::info!( + "found monitor and workspace configuration for {id} in the monitor cache, applying" + ); + + // If it does, update the cached monitor info with the new one and + // load the cached monitor removing any window that has since been + // closed or moved to another workspace + *m = Monitor { + // Data that should be the one just read from the compositor + id: m.id, + device: m.device.clone(), + serial_number_id: m.serial_number_id.clone(), + size: m.size, + work_area_size: m.work_area_size, + + // The rest should come from the cached monitor + work_area_offset: cached.work_area_offset, + window_based_work_area_offset: cached.window_based_work_area_offset, + window_based_work_area_offset_limit: cached + .window_based_work_area_offset_limit, + workspaces: cached.workspaces.clone(), + last_focused_workspace: cached.last_focused_workspace, + container_padding: cached.container_padding, + workspace_padding: cached.workspace_padding, + wallpaper: cached.wallpaper.clone(), + floating_layer_behaviour: cached.floating_layer_behaviour, + }; + + let focused_workspace_idx = m.focused_workspace_idx(); + + for (j, workspace) in m.workspaces_mut().iter_mut().enumerate() { + let is_focused_workspace = j == focused_workspace_idx; + let focused_container_idx = workspace.focused_container_idx(); + + let mut empty_containers = Vec::new(); + for (idx, container) in workspace.containers_mut().iter_mut().enumerate() { + container.windows_mut().retain(|window| { + window.exe().is_some() && !known_window_ids.contains_key(&window.id) + }); + + if container.windows().is_empty() { + empty_containers.push(idx); + } + + // If the focused window was moved or removed by the user after + // the disconnect then focus the first window + if is_focused_workspace && container.focused_window().is_none() { + container.focus_window(0); + } + } + + // Remove empty containers + for empty_idx in empty_containers { + if empty_idx == focused_container_idx { + workspace.remove_container(empty_idx); + } else { + workspace.remove_container_by_idx(empty_idx); + } + } + + if let Some(container) = &mut workspace.monocle_container { + container.windows_mut().retain(|window| { + window.exe().is_some() && !known_window_ids.contains_key(&window.id) + }); + + if container.windows().is_empty() { + workspace.monocle_container = None; + } else if is_focused_workspace && container.focused_window().is_none() { + // If the focused window was moved or removed by the user after + // the disconnect then focus the first window + container.focus_window(0); + } + } + + workspace.floating_windows_mut().retain(|window| { + window.exe().is_some() && !known_window_ids.contains_key(&window.id) + }); + + // Apply workspace rules + let mut workspace_matching_rules = WORKSPACE_MATCHING_RULES.lock(); + if let Some(rules) = workspace + .workspace_config + .as_ref() + .and_then(|c| c.workspace_rules.as_ref()) + { + for r in rules { + workspace_matching_rules.push(WorkspaceMatchingRule { + monitor_index: i, + workspace_index: j, + matching_rule: r.clone(), + initial_only: false, + }); + } + } + + if let Some(rules) = workspace + .workspace_config + .as_ref() + .and_then(|c| c.initial_workspace_rules.as_ref()) + { + for r in rules { + workspace_matching_rules.push(WorkspaceMatchingRule { + monitor_index: i, + workspace_index: j, + matching_rule: r.clone(), + initial_only: true, + }); + } + } + } + + // Restore windows from new monitor and update the focused + // workspace + m.load_focused_workspace(mouse_follows_focus)?; + m.update_focused_workspace(offset)?; + } + + // Entries in the cache should only be used once; remove the entry there was a cache hit + if cache_hit && !cached_id.is_empty() { + monitor_cache.remove(&cached_id); + } + } + } + + // Refocus the previously focused monitor since the code above might + // steal the focus away. + wm.focus_monitor(focused_monitor_idx)?; + wm.focus_workspace(focused_workspace_idx)?; + } + + let final_count = wm.monitors().len(); + + if post_removal_monitor_count != final_count { + wm.retile_all(true)?; + } + + let mouse_follows_focus = wm.mouse_follows_focus; + // the monitors are not the ones they were: show the workspace in front of each again + if initial_monitor_count != final_count { + for monitor in wm.monitors_mut() { + monitor.load_focused_workspace(mouse_follows_focus)?; + } + } + 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 @@ -103,9 +463,10 @@ pub fn listen_for_notifications(wm: Arc>) -> color_eyre::Re /// /// # 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. +/// Returns an error if [`reconcile_display_change`] fails for a +/// [`MonitorNotification::DisplayConnectionChange`], or if the subscribers cannot be notified; +/// [`listen_for_notifications`] then calls it again. The errors of a +/// [`MonitorNotification::Resize`] are logged. pub fn handle_notifications(wm: Arc>) -> color_eyre::Result<()> { tracing::info!("listening"); let receiver = event_rx(); @@ -124,6 +485,11 @@ pub fn handle_notifications(wm: Arc>) -> color_eyre::Result tracing::error!("failed to update monitor work areas: {error}"); } } + MonitorNotification::DisplayConnectionChange(_) => { + tracing::debug!("handling display connection change notification"); + let mut wm = wm.lock(); + reconcile_display_change(&mut wm)?; + } } { diff --git a/komorebi/src/reconcile.rs b/komorebi/src/reconcile.rs new file mode 100644 index 00000000..37b517c8 --- /dev/null +++ b/komorebi/src/reconcile.rs @@ -0,0 +1,175 @@ +//! Keeping komorebi's idea of where each window is in step with where the compositor has it, when +//! monitors come and go. +//! +//! 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. +//! +//! [`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`]). +//! +//! A window is on the monitor whose area has the middle of the window's frame. +//! +//! # What is guaranteed, and by what +//! +//! | 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 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 + +#![deny(missing_docs)] + +use crate::core::Rect; +use crate::window::Window; +use crate::window_manager::WindowManager; +use color_eyre::eyre; +use color_eyre::eyre::OptionExt; + +/// Returns true if and only if the point is in the area, the left and top edges included and the +/// right and bottom ones not (a [`Rect`]'s `right` and `bottom` are its width and height). +fn contains(area: &Rect, (x, y): (i32, i32)) -> bool { + x >= area.left && x < area.left + area.right && y >= area.top && y < area.top + area.bottom +} + +/// Returns the middle of a rect. +fn middle(rect: &Rect) -> (i32, i32) { + (rect.left + rect.right / 2, rect.top + rect.bottom / 2) +} + +impl WindowManager { + /// Returns the index of the monitor whose area has the middle of this rect, if any. + fn monitor_idx_holding(&self, rect: &Rect) -> Option { + let point = middle(rect); + self.monitors() + .iter() + .position(|monitor| contains(&monitor.size, point)) + } + + /// 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. + /// + /// Each container and floating window goes to the workspace of the same number on the monitor + /// that now has it, or on the first monitor that is left when that is not clear yet. A monocle + /// and a maximized window come back as ordinary containers. The monitor itself stays in the + /// list, empty, for the caller to remove. Nothing is done when there is no such monitor, or no + /// other. + /// + /// # Errors + /// + /// 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`. + pub fn evacuate_monitor(&mut self, serial_number_id: &str) -> eyre::Result<()> { + let Some(gone) = self + .monitors() + .iter() + .position(|m| m.serial_number_id == serial_number_id) + else { + return Ok(()); + }; + + let remaining: Vec = (0..self.monitors().len()).filter(|i| *i != gone).collect(); + let Some(first) = remaining.first().copied() else { + return Ok(()); + }; + + // where the middle of a window is now, if it is on a monitor that is left + let destination = |wm: &WindowManager, window: &Window| { + window + .rect() + .ok() + .and_then(|rect| wm.monitor_idx_holding(&rect)) + .filter(|idx| remaining.contains(idx)) + .unwrap_or(first) + }; + + let workspace_count = self.monitors()[gone].workspaces().len(); + + for w_idx in 0..workspace_count { + // take everything out of the workspace first, then place it + let (containers, floating, monocle, maximized) = { + let workspace = self + .monitors_mut() + .get_mut(gone) + .and_then(|m| m.workspaces_mut().get_mut(w_idx)) + .ok_or_eyre("there is no such workspace")?; + + let containers: Vec<_> = workspace.containers_mut().drain(..).collect(); + workspace.resize_dimensions.clear(); + let floating: Vec<_> = workspace.floating_windows_mut().drain(..).collect(); + let monocle = workspace.monocle_container.take(); + workspace.monocle_container_restore_idx = None; + let maximized = workspace.maximized_window.take(); + workspace.maximized_window_restore_idx = None; + + (containers, floating, monocle, maximized) + }; + + let place = |wm: &mut WindowManager, + window: Option, + f: Box| + -> eyre::Result<()> { + let to = window.map_or(first, |w| destination(wm, &w)); + let monitor = wm + .monitors_mut() + .get_mut(to) + .ok_or_eyre("there is no such monitor")?; + monitor.ensure_workspace_count(w_idx + 1); + let workspace = monitor + .workspaces_mut() + .get_mut(w_idx) + .ok_or_eyre("there is no such workspace")?; + f(workspace); + Ok(()) + }; + + for container in containers { + let window = container.focused_window().copied(); + place( + self, + window, + Box::new(move |workspace| workspace.containers_mut().push_back(container)), + )?; + } + + for window in floating { + place( + self, + Some(window), + Box::new(move |workspace| workspace.floating_windows_mut().push_back(window)), + )?; + } + + // a monocle, which shows one window of its container, comes as an ordinary container + if let Some(mut container) = monocle { + let window = container.focused_window().copied(); + let _ = container.restore(); + place( + self, + window, + Box::new(move |workspace| workspace.containers_mut().push_back(container)), + )?; + } + + // a maximized window is a window of an ordinary container on the monitor it goes to + if let Some(window) = maximized { + let mut container = crate::container::Container::default(); + container.add_window(&window)?; + place( + self, + Some(window), + Box::new(move |workspace| workspace.containers_mut().push_back(container)), + )?; + } + } + + Ok(()) + } +} diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index 79958ad6..18f71709 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -73,7 +73,9 @@ use crate::core::config_generation::MatchingRule; use crate::core::config_generation::MatchingStrategy; use crate::core::config_generation::WorkspaceMatchingRule; use crate::core::pathext::ResolvedPathBuf; +use crate::monitor; use crate::monitor::Monitor; +use crate::monitor_reconciliator; use crate::ring::Ring; use crate::theme_manager; use crate::transparency_manager; @@ -1364,6 +1366,13 @@ impl StaticConfig { } } + // Check if this monitor config is the preferred config for this monitor and store + // a copy of the monitor itself on the monitor cache if it is. + if idx == preferred_config_idx { + let id = &monitor.serial_number_id; + monitor_reconciliator::insert_in_monitor_cache(id, monitor.clone()); + } + let mut workspace_matching_rules = WORKSPACE_MATCHING_RULES.lock(); for (j, ws) in monitor_config.workspaces.iter().enumerate() { if let Some(rules) = &ws.workspace_rules { @@ -1391,6 +1400,53 @@ impl StaticConfig { } } + // Check for configs that should be tied to a specific display that isn't loaded right now + // and cache a monitor with those configs with the specific `serial_number_id` so that when + // those devices are connected later we can use the correct config from the cache. + if configs_with_preference.len() > configs_used.len() { + for i in configs_with_preference + .iter() + .filter(|i| !configs_used.contains(i)) + { + let id = { + let display_index_preferences = DISPLAY_INDEX_PREFERENCES.read(); + display_index_preferences.get(i).cloned() + }; + if let (Some(id), Some(monitor_config)) = + (id, value.monitors.as_ref().and_then(|ms| ms.get(*i))) + { + // The device and serial_number_id can be empty here since once the monitor + // with this preferred index actually connects the `load_monitor_information` + // function will update these fields. + let mut m = + monitor::new(0, Rect::default(), Rect::default(), "".into(), "".into()); + + m.ensure_workspace_count(monitor_config.workspaces.len()); + m.work_area_offset = monitor_config.work_area_offset; + m.window_based_work_area_offset = monitor_config.window_based_work_area_offset; + m.window_based_work_area_offset_limit = monitor_config + .window_based_work_area_offset_limit + .unwrap_or(1); + m.container_padding = monitor_config.container_padding; + m.workspace_padding = monitor_config.workspace_padding; + m.floating_layer_behaviour = monitor_config.floating_layer_behaviour; + + m.update_workspaces_globals(offset); + + for (j, ws) in m.workspaces_mut().iter_mut().enumerate() { + if let Some(workspace_config) = monitor_config.workspaces.get(j) { + ws.load_static_config( + workspace_config, + value.layout_defaults.as_ref(), + )?; + } + } + + monitor_reconciliator::insert_in_monitor_cache(&id, m); + } + } + } + wm.enforce_workspace_rules()?; if value.border == Some(true) { @@ -1489,6 +1545,13 @@ impl StaticConfig { } } + // Check if this monitor config is the preferred config for this monitor and store + // a copy of the monitor itself on the monitor cache if it is. + if idx == preferred_config_idx { + let id = monitor.serial_number_id.clone(); + monitor_reconciliator::insert_in_monitor_cache(&id, monitor.clone()); + } + let mut workspace_matching_rules = WORKSPACE_MATCHING_RULES.lock(); for (j, ws) in monitor_config.workspaces.iter().enumerate() { if let Some(rules) = &ws.workspace_rules { @@ -1516,6 +1579,53 @@ impl StaticConfig { } } + // Check for configs that should be tied to a specific display that isn't loaded right now + // and cache a monitor with those configs with the specific `serial_number_id` so that when + // those devices are connected later we can use the correct config from the cache. + if configs_with_preference.len() > configs_used.len() { + for i in configs_with_preference + .iter() + .filter(|i| !configs_used.contains(i)) + { + let id = { + let display_index_preferences = DISPLAY_INDEX_PREFERENCES.read(); + display_index_preferences.get(i).cloned() + }; + if let (Some(id), Some(monitor_config)) = + (id, value.monitors.as_ref().and_then(|ms| ms.get(*i))) + { + // The device and serial_number_id can be empty here since once the monitor + // with this preferred index actually connects the `load_monitor_information` + // function will update these fields. + let mut m = + monitor::new(0, Rect::default(), Rect::default(), "".into(), "".into()); + + m.ensure_workspace_count(monitor_config.workspaces.len()); + m.work_area_offset = monitor_config.work_area_offset; + m.window_based_work_area_offset = monitor_config.window_based_work_area_offset; + m.window_based_work_area_offset_limit = monitor_config + .window_based_work_area_offset_limit + .unwrap_or(1); + m.container_padding = monitor_config.container_padding; + m.workspace_padding = monitor_config.workspace_padding; + m.floating_layer_behaviour = monitor_config.floating_layer_behaviour; + + m.update_workspaces_globals(offset); + + for (j, ws) in m.workspaces_mut().iter_mut().enumerate() { + if let Some(workspace_config) = monitor_config.workspaces.get(j) { + ws.load_static_config( + workspace_config, + value.layout_defaults.as_ref(), + )?; + } + } + + monitor_reconciliator::insert_in_monitor_cache(&id, m); + } + } + } + wm.enforce_workspace_rules()?; if let Some(border_enabled) = value.border { diff --git a/komorebi/tests/hotplug.rs b/komorebi/tests/hotplug.rs new file mode 100644 index 00000000..d67e514e --- /dev/null +++ b/komorebi/tests/hotplug.rs @@ -0,0 +1,346 @@ +//! Monitors that are unplugged and plugged back, against an in-memory desktop. +//! +//! The compositor takes the windows of an output that is unplugged to the ones that are left, and +//! komorebi has to take them over there: it is the only thing that can tile them. Plugged back, the +//! monitor comes back with what komorebi kept of it; a monitor komorebi never saw gets workspaces of +//! its own. +//! +//! 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::DefaultLayout; +use komorebi::core::Layout; +use komorebi::core::Rect; +use komorebi::core::SocketMessage; +use komorebi::monitor_reconciliator::reconcile_display_change; +use komorebi::static_config::Wallpaper; +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::path::PathBuf; +use std::sync::Arc; + +fn area(left: i32) -> Rect { + Rect { + left, + top: 0, + right: 1920, + bottom: 1080, + } +} + +fn work_area(left: i32) -> Rect { + Rect { + left, + top: 0, + right: 1920, + bottom: 1050, + } +} + +fn desktop_of(names: &[&str]) -> (Arc, WindowManager) { + let fake = Arc::new(FakeBackend::new()); + for (i, name) in names.iter().enumerate() { + fake.add_monitor( + 50 + i as u32, + name, + area(1920 * i as i32), + work_area(1920 * i as i32), + ); + } + + let (_sender, receiver) = crossbeam_channel::bounded(1); + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let socket = std::path::PathBuf::from(format!( + "/tmp/kmh-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + )); + + let wm = with_backend(fake.clone(), || { + let mut wm = WindowManager::new(receiver, Some(socket)).unwrap(); + wm.init().unwrap(); + wm + }); + + (fake, wm) +} + +fn added(id: u32) -> WindowManagerEvent { + WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + Window::from(id), + ) +} + +/// where a window opens: somewhere in the area that starts at `left` +fn somewhere_in(left: i32) -> Rect { + Rect { + left: left + 200, + top: 200, + right: 800, + bottom: 600, + } +} + +fn open_on(fake: &Arc, wm: &mut WindowManager, id: u32, left: i32) { + fake.open_window(id, "foot", "foot", &format!("terminal {id}")); + fake.move_window_by_user(id, somewhere_in(left)); + with_backend(fake.clone(), || wm.process_event(added(id))).unwrap(); +} + +fn containers_on(wm: &WindowManager, monitor: usize) -> usize { + wm.monitors()[monitor] + .focused_workspace() + .map_or(0, |w| w.containers().len()) +} + +fn inside(rect: &Rect, area: &Rect) -> bool { + rect.left >= area.left + && rect.top >= area.top + && rect.left + rect.right <= area.left + area.right + && rect.top + rect.bottom <= area.top + area.bottom +} + +fn overlap(a: &Rect, b: &Rect) -> bool { + a.left < b.left + b.right + && b.left < a.left + a.right + && a.top < b.top + b.bottom + && b.top < a.top + a.bottom +} + +fn assert_tiled_in(fake: &FakeBackend, ids: &[u32], area: &Rect) { + let rects: Vec = ids.iter().map(|id| fake.rect_of(*id).unwrap()).collect(); + for (i, a) in rects.iter().enumerate() { + assert!(inside(a, area), "{a:?} is not inside {area:?}"); + for b in &rects[i + 1..] { + assert!(!overlap(a, b), "{a:?} and {b:?} overlap"); + } + } +} + +#[test] +fn a_monitor_that_was_never_there_gets_windows_of_its_own() { + let (fake, mut wm) = desktop_of(&["Test-C"]); + open_on(&fake, &mut wm, 50_101, 0); + + fake.add_monitor(61, "Test-D", area(1920), work_area(1920)); + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + assert_eq!(wm.monitors().len(), 2); + + open_on(&fake, &mut wm, 50_102, 1920); + open_on(&fake, &mut wm, 50_103, 1920); + + assert_eq!(containers_on(&wm, 0), 1); + assert_eq!(containers_on(&wm, 1), 2); + assert_tiled_in(&fake, &[50_102, 50_103], &work_area(1920)); +} + +#[test] +fn a_stack_goes_with_the_monitor_as_a_stack() { + let (fake, mut wm) = desktop_of(&["Test-G", "Test-H"]); + open_on(&fake, &mut wm, 50_301, 0); + for id in [50_302, 50_303] { + open_on(&fake, &mut wm, id, 1920); + } + // 50_303 onto 50_302's container on the second monitor + wm.focus_monitor(1).unwrap(); + with_backend(fake.clone(), || Window::from(50_303).focus(false)).unwrap(); + with_backend(fake.clone(), || { + wm.process_command( + SocketMessage::StackWindow(komorebi::core::OperationDirection::Left), + Vec::new(), + ) + }) + .unwrap(); + assert_eq!(containers_on(&wm, 1), 1, "the two windows are one stack"); + + fake.remove_monitor(51); + for id in [50_302, 50_303] { + fake.move_window_by_user(id, somewhere_in(100)); + } + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!(wm.monitors().len(), 1); + let stacked = wm.monitors()[0] + .focused_workspace() + .unwrap() + .containers() + .iter() + .any(|c| c.windows().len() == 2); + assert!(stacked, "the stack was taken apart"); +} + +#[test] +fn the_windows_of_a_monitor_that_goes_are_tiled_on_the_one_that_is_left() { + let (fake, mut wm) = desktop_of(&["Test-J", "Test-K"]); + for id in [50_501, 50_502] { + open_on(&fake, &mut wm, id, 0); + } + for id in [50_503, 50_504] { + open_on(&fake, &mut wm, id, 1920); + } + assert_eq!(containers_on(&wm, 0), 2); + 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 in [50_503, 50_504] { + fake.move_window_by_user(id, somewhere_in(100)); + } + 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), 4, "its windows were not taken over"); + for id in 50_501..=50_504 { + 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_501, 50_502, 50_503, 50_504], &work_area(0)); +} + +#[test] +fn a_monitor_that_comes_back_gets_what_was_kept_of_it_and_none_of_the_windows_it_had() { + let (fake, mut wm) = desktop_of(&["Test-L", "Test-M"]); + open_on(&fake, &mut wm, 50_601, 0); + open_on(&fake, &mut wm, 50_602, 1920); + wm.monitors_mut()[1].workspace_padding = Some(40); + wm.monitors_mut()[1].workspaces_mut()[0].layout = Layout::Default(DefaultLayout::Columns); + wm.monitors_mut()[1].wallpaper = Some(Wallpaper { + path: PathBuf::from("/tmp/kmf-hotplug-wallpaper.png"), + generate_theme: Some(false), + theme_options: None, + }); + + fake.remove_monitor(51); + fake.move_window_by_user(50_602, somewhere_in(100)); + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + assert_eq!(wm.monitors().len(), 1); + assert_eq!(containers_on(&wm, 0), 2); + + fake.add_monitor(51, "Test-M", 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"); + let monitor = &wm.monitors()[1]; + assert_eq!(monitor.workspace_padding, Some(40)); + assert_eq!( + monitor.workspaces()[0].layout, + Layout::Default(DefaultLayout::Columns) + ); + assert_eq!( + monitor + .wallpaper + .as_ref() + .map(|wallpaper| wallpaper.path.clone()), + Some(PathBuf::from("/tmp/kmf-hotplug-wallpaper.png")), + "it came back without its wallpaper" + ); + assert_eq!( + containers_on(&wm, 1), + 0, + "it came back with a window that is tiled elsewhere" + ); + assert_eq!(containers_on(&wm, 0), 2); +} + +#[test] +fn a_desktop_that_reports_no_monitor_is_left_as_it_was() { + let (fake, mut wm) = desktop_of(&["Test-N"]); + open_on(&fake, &mut wm, 50_701, 0); + + fake.remove_monitor(50); + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!(wm.monitors().len(), 1); + assert_eq!(containers_on(&wm, 0), 1); +} + +#[test] +fn a_monocle_of_a_monitor_that_goes_comes_back_as_a_container_with_the_windows_it_hid() { + let (fake, mut wm) = desktop_of(&["Test-O", "Test-P"]); + open_on(&fake, &mut wm, 50_801, 0); + for id in [50_802, 50_803] { + open_on(&fake, &mut wm, id, 1920); + } + wm.focus_monitor(1).unwrap(); + with_backend(fake.clone(), || { + wm.process_command(SocketMessage::ToggleMonocle, Vec::new()) + }) + .unwrap(); + let monocle = wm.monitors()[1].focused_workspace().unwrap(); + assert!(monocle.monocle_container.is_some(), "there is no monocle"); + assert!(fake.is_hidden(50_802) || fake.is_hidden(50_803)); + + fake.remove_monitor(51); + for id in [50_802, 50_803] { + fake.move_window_by_user(id, somewhere_in(100)); + } + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!(wm.monitors().len(), 1); + let workspace = wm.monitors()[0].focused_workspace().unwrap(); + assert!(workspace.monocle_container.is_none()); + assert_eq!( + containers_on(&wm, 0), + 3, + "a window was lost with the monocle" + ); + for id in 50_801..=50_803 { + assert!(!fake.is_hidden(id), "window {id} is still hidden"); + } + assert_tiled_in(&fake, &[50_801, 50_802, 50_803], &work_area(0)); +} + +#[test] +fn a_maximized_window_of_a_monitor_that_goes_is_not_lost() { + let (fake, mut wm) = desktop_of(&["Test-Q", "Test-R"]); + open_on(&fake, &mut wm, 50_901, 0); + for id in [50_902, 50_903] { + open_on(&fake, &mut wm, id, 1920); + } + wm.focus_monitor(1).unwrap(); + with_backend(fake.clone(), || { + wm.process_command(SocketMessage::ToggleMaximize, Vec::new()) + }) + .unwrap(); + let maximized = wm.monitors()[1] + .focused_workspace() + .unwrap() + .maximized_window + .map(|window| window.id) + .expect("there is no maximized window"); + + fake.remove_monitor(51); + for id in [50_902, 50_903] { + fake.move_window_by_user(id, somewhere_in(100)); + } + with_backend(fake.clone(), || reconcile_display_change(&mut wm)).unwrap(); + + assert_eq!(wm.monitors().len(), 1); + assert!( + wm.monitors()[0] + .focused_workspace() + .unwrap() + .contains_window(maximized), + "the maximized window was lost with its monitor" + ); + for id in 50_901..=50_903 { + assert_eq!( + wm.known_window_ids.get(&id).map(|(m, _)| *m), + Some(0), + "window {id} is not managed on the monitor that is left" + ); + } +} diff --git a/nix/tests/vm/suites/hotplug/test.py b/nix/tests/vm/suites/hotplug/test.py index 8d1a5d1e..c55529c7 100644 --- a/nix/tests/vm/suites/hotplug/test.py +++ b/nix/tests/vm/suites/hotplug/test.py @@ -1,8 +1,10 @@ # 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. +# 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 W, H = 1920, 1080 @@ -23,18 +25,39 @@ def monitor(idx: int) -> Rect: 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. +def monitors() -> int: + """How many monitors komorebi has.""" + return len(kde.state()["monitors"]["elements"]) + + +def containers_on(idx: int) -> int: + """How many containers the workspace in front of a monitor has in komorebi.""" + workspaces = kde.state()["monitors"]["elements"][idx]["workspaces"] + return len(workspaces["elements"][workspaces["focused"]]["containers"]["elements"]) + + +def rects(titles: list[str]) -> list[Rect]: + """The frames KWin has for these windows.""" + return [kde.rect(kde.wid(title)) for title in titles] + + +def tiled_in(titles: list[str], area: Rect) -> bool: + """Every one of these 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 :] + frames = rects(titles) + return all(r.inside(area, slack=1) for r in frames) and not any( + a.overlaps(b) for i, a in enumerate(frames) for b in frames[i + 1 :] ) +def on_the_right() -> list[str]: + """The windows that are on the right output, as KWin has them.""" + return [t for t, r in zip(TITLES, rects(TITLES), strict=True) if r.x >= W] + + 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") @@ -46,21 +69,17 @@ 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, - ), + kde.wait_for(lambda: kde.run("komorebic state")[0] == 0 and monitors() == 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), + kde.wait_for(lambda: containers_on(0) == 3 and containers_on(1) == 0), ) 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)))) +checks.check("the windows are tiled inside it", kde.wait_for(lambda: tiled_in(TITLES, monitor(0)))) print("== 2. the scale of the left output changes") before = resizes_handled() @@ -73,21 +92,74 @@ checks.check( 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)), + kde.wait_for(lambda: tiled_in(TITLES, 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), + "the monitor has its size back, and the windows fill it again", + kde.wait_for(lambda: monitor(0) == Rect(0, 0, W, H) and tiled_in(TITLES, monitor(0)), 20), ) -print("== 4. nothing is left behind") +print("== 3. two windows go to the right monitor") +kde.komorebic("move-to-monitor", "1") +kde.komorebic("focus-monitor", "0") +kde.komorebic("move-to-monitor", "1") +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), +) +checks.check( + "the two are tiled on the right output, and the third fills the left one", + kde.wait_for( + lambda: len(on_the_right()) == 2 + and tiled_in(on_the_right(), monitor(1)) + and tiled_in([t for t in TITLES if t not in on_the_right()], monitor(0)) + ), +) + +print("== 4. 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)) +checks.check( + "they are tiled inside the screen that is left, and not on each other", + kde.wait_for(lambda: tiled_in(TITLES, Rect(0, 0, W, H))), +) +checks.check( + "komorebi is alive and its idea of the focus is KWin's", + kde.wait_for( + lambda: kde.komorebi_running() + and kde.active() != "" + and kde.title(kde.active()) == kde.focused_title(0) + ), +) + +print("== 5. 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)) +checks.check( + "each window is somewhere it can be seen", + kde.wait_for( + lambda: all( + r.inside(Rect(0, 0, W, H), 1) or r.inside(Rect(W, 0, W, H), 1) for r in rects(TITLES) + ) + ), +) + +print("== 6. and a window can be sent over again") +kde.komorebic("focus-monitor", "0") +kde.komorebic("move-to-monitor", "1") +checks.check( + "komorebi has the window 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))), +) + +print("== 7. nothing is left behind") checks.check( "komorebi is running and answers", kde.komorebi_running() and kde.run("komorebic state")[0] == 0,