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,