# The core model: KDE and Windows compared, and the split into `komorebi-core` > **Status (wip-cand4):** measurement plus a running log. `komorebi-core` has `Container`, `Monitor`, `Workspace` (incl. `update_shared`) over KDE, Windows and macOS; `WindowManager

`, the router and `process_event` are NOT shared yet (sections 17 and 18 give the blockers); sections 8 and 12 are the designs that were then implemented. > **Paths (cutover stack):** this document was written before the directories were renamed and keeps the paths of its time. Now: `komorebi/` is `komorebi-windows/`, `komorebi-for-kde/komorebi/` is `komorebi-linux/`, `komorebi-for-mac/komorebi/` is `komorebi-macos/`, `komorebi-for-kde/komorebi-hotkeys/` is `komorebi-hotkeys/`, `komorebi-for-kde/nix/` and `komorebi-for-mac/nix/` are `nix/linux/` and `nix/macos/`, `komorebi-for-kde/docs/` and `komorebi-for-mac/docs/` are `docs/linux/` and `docs/macos/`, `schema*.json` are in `schemas/{windows,linux,macos}/`, and every package is at version 0.2.0 (the 0.1.x numbers below are history). Status: measurement and proposal (plan step 9 preparation, KDE side). Measured at the tip of `wip-u-kfde` (based on `wip-main` 633e7453); the Windows tree is `komorebi/src`, KDE is `komorebi-for-kde/komorebi/src`, macOS (`komorebi-for-mac/komorebi/src`) is listed only where it helps. Nothing here has been run on Windows or macOS. ## 1. Measured differences "Code lines" drop `//` lines and blank lines and strip indentation, so KDE's much heavier doc comments do not count; the inline `#[cfg(test)]` modules are included. "Changed" is the number of `<`/`>` lines of `diff` between the two stripped files (an insertion of n lines counts n, a replacement of n lines counts 2n). "Identical" is the number of stripped lines present in both in the same order (a common subsequence, so it is a lower bound on shared text). | file | code lines Windows / KDE / macOS | changed W vs K | changed K vs M | changed W vs M | identical W&K | |---|---|---|---|---|---| | `container.rs` | 230 / 254 / 241 | 196 | 119 | 185 | 144 | | `monitor.rs` | 643 / 666 / 675 | 613 | 205 | 616 | 348 | | `workspace.rs` | 2203 / 2491 / 2196 | 2314 | 1097 | 2113 | 1190 | | `window.rs` | 1134 / 916 / 1641 | 1168 | 1413 | 1767 | 441 | | `window_manager.rs` | 4716 / 4023 / 4896 | 4543 | 1597 | 5434 | 2098 | Whole-file raw `diff` sizes (comments included): container 271, monitor 752, workspace 3167, window 1520, window_manager 6358 changed lines (Windows vs KDE). `ring.rs` is already shared (`crates/komorebi-collections`, `Ring`, `Lockable`). Two things stand out. First, every file KDE and macOS share is much closer to each other than either is to Windows (K vs M changed is a third to a half of W vs K for monitor, workspace, window_manager, static_config, state). Windows is the odd one out, mostly because it carries older and larger machinery (see section 2). Second, the structs themselves are almost the same: `Workspace` has 31 fields on both sides and they differ only in the order of `work_area_offset_rules`/`work_area_offset`; the differences are in the methods around them. ## 2. Platform-specific fields and methods, per layer ### container (smallest) - Same fields (`id`, `locked`, `windows: Ring`). Windows' `windows` is private and `locked` has `#[serde(default)]`; KDE's `windows` is `pub`. - Window id type: `hwnd: isize` (Windows) vs `id: u32` (KDE) in `contains_window`, `idx_for_window`, `hide(omit)`. - Windows only: `hwnd_from_exe` (KDE keeps only `idx_from_exe`), `Window::restore_with_border` / `hide_with_border` in `load_focused_window` (Windows' border and stackbar are separate windows). `exe()` returns `Result` on Windows, `Option` on KDE. - After the converge revisions of this stack the signatures are the same (`add_window(Window)`, `hide(&self, Option)`, `restore(&self)`, `load_focused_window(&mut self)`, no `Result`) and the item order is the same. What is left is the id type and the three Windows-only lines above. ### monitor - Windows only fields: `name`, `device_id`, `workspace_names: HashMap`; `serial_number_id` is `Option` (KDE: `String`); `id` is `isize` (KDE `u32`). - Windows only methods: none in the public API; KDE has `assign_windows_to_workspaces` (it pushes the workspace of each window to the compositor's desktops, `Backend::set_window_workspace`). - Behaviour that differs: Windows `load_focused_workspace` restores/hides workspaces itself, KDE asks the compositor to switch desktops (`Backend::switch_workspace`) and tiles what is in front; the Windows `update_workspaces_globals` pushes padding/border/work area into every workspace (`WorkspaceGlobals`), KDE calls `update_workspace_globals(idx, offset)` for the focused one. KDE has a `MonitorInformation` with 4 fields, Windows 6. - `BORDER_ENABLED` (Windows) vs `BORDER_DRAWN` (KDE): the compositor draws borders. ### workspace - Windows only methods: `windows_on_show`, `border_kinds_on_show`, `hwnd_from_exe`. - KDE only methods: `adopt_maximized_window`, `focus_floating_window`, `focus_previous_floating_window`, `prune_duplicate_windows`, `reap_invalid_windows`, `remove_floating_window` (my reading, not verified: KDE learns of closes and moves through the bridge snapshot, so the workspace reaps and prunes). - 42 of 45/48 `pub fn` names are common. Windows `restore`/`hide` take the monitor handle and wallpaper and do the showing and hiding (`WindowsApi`, 8 mentions in the file); KDE leaves showing to the compositor (5 `backend()` calls). - Scrolling layout glue (`scrolling.rs`): 1328 lines KDE, 1315 macOS, ~2200 Windows; measured first, not part of this comparison. ### window (not shared: the platform handle) - `Window { hwnd: isize }` (Windows, methods that call `WindowsApi`, 1134 code lines) vs `Window { id: u32 }` (KDE, `Copy`, every method calls `backend()`, 916 code lines) vs macOS (AX element, application, observer; not `Copy`, 1641 code lines). - Both Windows and KDE `Window` are `Copy` handles, which is what lets the container and workspace code hold windows by value. macOS is not (clone). - `Window` is where the split goes: the core must not call a method of `Window` that does not exist on all three. ### window_manager - Windows only fields: `known_hwnds`, `virtual_desktop_id`, `uncloack_to_ignore`, `has_pending_raise_op`, `pending_move_op` with a handle and rect, `already_moved_window_handles` of `isize`. - KDE only fields: `opacity_applied`, `minimized_windows`, `pending_resize_op`, `live_resize`, `known_window_ids` (a `u32` map); `pending_move_op` has no rect. - 43 `WindowsApi` mentions in Windows' `window_manager.rs`, 15 `backend()` in KDE's. Every one of them becomes a `Backend` call or a `Window` method in the core. - `process_command` and `process_event` are not in this table (see section 4 and the plan). ## 3. Proposed split: `komorebi-core` ``` komorebi-core (new crate, no platform dependency) ring, container, monitor, workspace, window_manager (generic) trait Platform: Backend + { type Window; hooks } (what is not in Backend) komorebi-backend (exists: Backend, WindowId, MonitorId, WindowInfo, Error, Capability) komorebi (windows) / (kde) / (mac) Window (the platform handle), the Backend implementation, event sources, borders, ... ``` Generic over what: - `WindowManager`, with `Platform::Window: Copy + Eq + Hash + Serialize + Deserialize` and `fn id(&self) -> WindowId`. The three id types (`isize`, `u32`, `u32`) disappear into `WindowId` (`WindowId::from_hwnd`/`from_u32`, already in `komorebi-backend`). macOS needs `Clone` instead of `Copy`: the generic code has to take `&P::Window` and clone, or macOS keeps a `Copy` handle and an id registry as the seam doc proposes (docs/dev/seam.md section 7, consequence 1). - `Backend` is a trait object or a type parameter; `docs/dev/seam.md` and the KDE conversion show it can be the shared trait unchanged, plus KDE's extension (`drag_*`, look, wallpaper) behind `Platform` hooks. - Methods that are still platform behaviour (`Window::hide_with_border`, showing and hiding a workspace on Windows vs asking for a desktop on KDE) move behind two hooks: `Platform::show_workspace(monitor, idx)` and `Platform::hide_workspace(...)`, with the Windows implementation hiding/restoring windows and the KDE one calling `Backend::switch_workspace`. ### Exact order of layers (bottom-up, as in the plan) 1. **ring** is done (`komorebi-collections`). 2. **container** (230 lines): needs `Window` generic and `id()`. Smallest; do first. Windows must rename `hwnd` parameters to `WindowId` or an `id()` accessor and give `exe()` the KDE return type (`Option`) or both use `Result`. 3. **monitor** (643): needs `Workspace` generic, `MonitorId`, and the `Platform` show/hide hooks. Windows' extra fields (`name`, `device_id`, `workspace_names`) stay in the shared struct as `Option`/default fields or become a `P::MonitorExtra` associated type; KDE `serial_number_id` becomes `Option` with `""` mapped to `None`. 4. **workspace** (2203): by far the biggest; the platform split is `restore/hide/windows_on_show/ border_kinds_on_show` (Windows) and `reap/prune/adopt_maximized` (KDE). Scrolling glue is measured before it moves. 5. **window_manager** (4716): after the first three are shared. The platform fields (section 2) go in a `P::State` associated type. 6. the flat router and `process_event` (separate steps in the plan). ## 4. What KDE has done (this stack) and what Windows must do before the crate exists Landed on `wip-u-kfde` (all native tests green, none of it run on a VM): - KDE's `Backend` extends the shared trait; ids, errors and info types are the shared ones at the trait; the core still holds `Window { id: u32 }` and converts at the call. - `container.rs` and `monitor.rs` are in the same order as Windows', and `Container` has the Windows signatures (`add_window(Window)`, infallible `hide`, `restore`, `load_focused_window`). - All `process_command` arms longer than 20 lines are named methods (list in section 5). I did not create the shared crate: the Windows tree still has `isize` handles in `Container`, `Monitor::id: isize`, and calls `WindowsApi` directly in 43 places of `window_manager.rs`. Windows (kfw) has to: 1. Finish adopting `Backend` in `window_manager.rs`, `workspace.rs`, `monitor.rs` (the 8 + 43 + 2 `WindowsApi` call sites there), so no core file names `WindowsApi`. 2. Give `Container` the same API KDE now has: accept a `WindowId` (or `Window::id()`), return `Option` from `exe()`, and drop `hwnd_from_exe` or move it to a platform extension. 3. Make `Monitor.id` and `serial_number_id` the shared types (`MonitorId`, `Option`), which changes what `komorebic state` prints for Windows (a BEHAVIOUR change for the JSON; a test must pin it). 4. Convert its own multi-line `process_command` arms (the plan counts 22-23 per platform) into named methods with the same names KDE used (section 5), so the router can later be shared. ## 5. `process_command`: arms over 20 lines (KDE, before conversion) | arm | lines | now | |---|---|---| | ToggleWorkspaceLayer | 90 | `toggle_workspace_layer()` | | EagerFocus | 75 | `eager_focus(exe)` | | ResizeWindowAxis | 60 | `resize_window_axis(axis, sizing)` | | IgnoreRule | 55 | `add_ignore_rule(identifier, id)` | | Query | 51 | `answer_query(query, reply)` | | SessionFloatRule | 39 | `add_session_float_rule()` | | ReplaceConfiguration | 34 | `replace_configuration(config)` | | CycleFocusEmptyWorkspace | 32 | `cycle_focus_empty_workspace(direction)` | | CloseWorkspace | 29 | `close_workspace()` | | MoveContainerToLastWorkspace | 28 | `move_container_to_last_workspace(follow)` | | SendContainerToLastWorkspace | 27 | same method, `follow = false` | | CycleFocusWorkspace | 26 | `cycle_focus_workspace(direction)` | | LayoutRatios | 23 | `set_layout_ratios(columns, rows)` | | BorderColour | 21 | `set_border_colour(kind, r, g, b)` | | NamedWorkspaceRule | 21 | `add_named_workspace_rule(identifier, id, workspace)` | | InitialNamedWorkspaceRule | 21 | `add_initial_named_workspace_rule(identifier, id, workspace)` | | ToggleWorkspaceWindowContainerBehaviour | 21 | `toggle_workspace_window_container_behaviour()` | All are `pub fn name(&mut self, ...) -> eyre::Result<()>` in `impl WindowManager` (`process_command.rs`), except `set_border_colour`, which cannot fail. ## 6. First layer moved: `Container` is in `komorebi-core` (wip-u-core) Done, on a throwaway merge of `wip-u-kfde` and `wip-u-kfw` (bookmark `wip-u-core`): - `crates/komorebi-core` (root workspace member; KDE takes it by path) holds `Container`, moved from the KDE file and made generic, and the trait `ContainerWindow` that says what a container asks of a platform window: `type Id` (`isize` on Windows, `u32` on KDE), `id`, `exe_name`, `hide_window`, `restore_window`, and the pair `hide_stacked` / `restore_stacked` that `load_focused_window` uses (on Windows they leave the border alone, on KDE they are the plain ones). The container makes no `Backend` call itself. - KDE and Windows each have `type Container = komorebi_core::Container` plus the `ContainerWindow` impl for their `Window`. Windows keeps `hwnd_from_exe` as the extension trait `ContainerExt` (workspace.rs imports it). The macOS tree does not use the crate yet and still type-checks. - The JSON of a container is pinned by a test in each tree and in the crate (`id`, `locked`, `windows: {elements, focused}`); `locked` may be missing when read (KDE gained that from Windows); the schemars schema of `Container` is renamed to `Container`, with a test. - The Windows converge is marked BEHAVIOUR because it was only type-checked (clippy) and the crate's tests run natively; the Windows e2e scenarios it needs are in that revision's body. ### Where it stops: `Monitor` After kfw's convergence the Windows and KDE `monitor.rs` still differ by 657 changed code lines (710 vs 675 lines), and the difference is not mechanical: - **Fields**: Windows has `name`, `device_id`, `workspace_names: HashMap`; `serial_number_id` is `Option` (KDE `String`); `id` is a `MonitorId` that the Windows file serialises through `hmonitor_json` as an `i64` (KDE: a plain `u32`). Making them the same changes what one daemon's `komorebic state` prints, so it is a decision, not a refactor: either a `Monitor

` with an associated `MonitorExtra` (Windows: the three fields) and `MonitorId` with a serde adapter per platform, or the extra fields as `Option`s that KDE leaves `None` and skips when serialising. - **Methods**: `MonitorInformation` has 6 fields on Windows and 4 on KDE; Windows has the two serde helpers, KDE has `assign_windows_to_workspaces`. The 16 other public methods are common, but `load_focused_workspace` (Windows hides and restores the workspaces itself, KDE asks the compositor for the desktop) and `update_workspaces_globals` / `update_workspace_globals` (Windows pushes the globals into every workspace, KDE into the focused one) differ in behaviour, and need the `Platform::show_workspace` / `hide_workspace` hooks of section 3. - `BORDER_ENABLED` (Windows) vs `BORDER_DRAWN` (KDE) are separate statics. ### What remains, in order 1. `Monitor`: decide the field-set question above, add the two workspace hooks to a `Platform` trait (the first thing in the crate that is a `Platform` rather than a window), converge the method bodies, then move. The diff after that decision should be a few dozen lines. 2. `Workspace`: 2314 changed code lines. Needs, besides the hooks, the Windows-only `windows_on_show` / `border_kinds_on_show` and the KDE-only `reap_invalid_windows`, `prune_duplicate_windows`, `adopt_maximized_window`, `focus_floating_window`, `focus_previous_floating_window`, `remove_floating_window` as optional `Platform` hooks or extension traits; the scrolling glue is measured before it moves. 3. `WindowManager`, then the router and `process_event` (unchanged from section 3). ## 7. Classification of the `workspace.rs` and `monitor.rs` differences Method: each file is cut into functions (impl-level and free `fn`s, tests apart), comments and blank lines are dropped, and each function is compared with its namesake on the other side (`diff`; a changed line counts on both sides). The earlier whole-file figures (workspace 2314, monitor 657) come from one `diff` of the stripped files, which misaligns after every insertion; per function the picture is much smaller. Classes: **(a)** cosmetic or ordering drift, **(b)** same behaviour written differently, **(c)** a real platform difference (a hook is needed), **(d)** a feature one side lacks. Measured at the tip of `wip-u-core` (Container already moved; the Windows tree is kfw's `wip-u-kfw`). ### `workspace.rs` | | functions | changed lines | |---|---|---| | identical on both sides (non-test) | 36 | 0 | | (a) drift | 1 | 31 | | (b) same behaviour, different text | 15 | 196 | | (c) platform difference | 6 | 356 | | (d) feature on one side only | 14 | 270 | | tests present on both sides | 26 | 71 (all (b): the id rename) | | tests only in KDE | 20 | 275 | (c) is `update` (161: Windows adds title bars, stackbars, the animation batch, the scrolling `adopt_actual_widths` pass and `live_resize::is_stepping`; KDE clamps layouts to the work area, has the percentage override, `scrolling_placed` and `has_settled`), `apply_wallpaper` (110: Windows writes the wallpaper through `WindowsApi` and builds the palette theme inline, KDE asks the backend and calls `theme_manager::wallpaper_palette`), `remove_window` (41: Windows deletes the border and returns `()`; KDE returns the `Window` and unmaximizes it), `hide` (28: KDE re-runs `update` for a scrolling workspace whose resize dimensions are set), `reintegrate_maximized_window` (12: KDE unmaximizes and adds through `add_window`; Windows loads the focused window) and `new_floating_window` (4: KDE unmaximizes). (d) Windows: `border_kinds_on_show` (34), `windows_on_show` (10), `hwnd_from_exe` (26), custom layouts in `load_static_config` (23), `promote_container` (5), `effective_layout_options` (1). KDE: `adopt_maximized_window` (60), `prune_duplicate_windows` (40), `reap_invalid_windows` (33), `remove_floating_window` (19), `focus_floating_window` (4), `focus_previous_floating_window` (4), `percentage_within_horizontal_bounds` (11), and the `scrolling_placed` field. (b) is almost all of: `hwnd: isize` against `window_id: u32` (`contains_window`, `contains_managed_window`, `container_idx_for_window`, `container_for_window`, `focus_container_by_window`, `is_focused_window_monocle_or_maximized`, `move_container_to_workspace` of the monitor), `WindowsApi::foreground_window()` / `cursor_pos()` against `backend()`, `let _ = backend().hide(WindowId::from_hwnd(..))` against `window.hide()` (also restore, focus, maximize), `Option` against `Result` for the title, exe and class (`resolve_placement_index`, `location_from_exe`), `try_into` against `into` (`visible_window_details`), `new_container_for_window(Window)` against `(&Window) -> Result<()>`, and `by value` against `&self.maximized_window` patterns. ### `monitor.rs` | | functions | changed lines | |---|---|---| | identical (non-test) | 8 | 0 | | (b) | 2 | 18 (`move_container_to_workspace`, `add_container`) | | (c) | 3 | 40 (`load_focused_workspace`, `update_workspaces_globals`, `update_workspace_globals`) | | (d) | 9 | 113 (the Windows `name`, `device_id` and `workspace_names` fields: `new`, `placeholder`, `From`, `focus_workspace`, the id serde adapter; Custom layouts in `add_container_with_direction`; KDE `assign_windows_to_workspaces`) | ### What each remaining cluster needs KDE-side converge revisions (this stack): the (a) and (b) clusters where the Windows form is the better one. What stays, and what kfw does (the list to hand over): 1. **id parameter name and type** (b): KDE keeps `window_id`; kfw renames `hwnd` to `window_id` and takes the id as the `ContainerWindow::Id` (`isize`) so the text is the same. 2. **Backend calls inline** (b): kfw's `backend().hide(WindowId::from_hwnd(..))` and KDE's `window.hide()` both become `ContainerWindow::hide_window()` / `restore_window()` (already in the crate); focus and maximize become `Window::focus` and `Window::maximize` on both. kfw: Windows `Window` needs `focus`, `maximize`, `unmaximize` that call `backend()`; the Windows result handling (`let _ =`) moves inside them. 3. **`Option` against `Result`** for title, exe, class, path (b): kfw's `Window::title/exe/class/path` return `Option` (as KDE) or both return `Result`; recommended `Option`, since Windows never distinguishes the failures. 4. **`WindowDetails`** (b): `From` (KDE) against `TryFrom` (Windows); kfw makes Windows' infallible (the `Err` arms were never distinguished). 5. **`new_container_for_window(Window)`** (b): KDE adopts the Windows form in this stack. 6. **`remove_focused_floating_window`** (b): kfw adopts KDE's two-line form over `remove_floating_window` (which Windows lacks: add it, it is 19 lines). 7. **Error strings** of `reintegrate_maximized_window` (b): KDE's ("no maximized restore index") are right; Windows' copy the monocle ones. kfw adopts KDE's. 8. **(c) hooks**, to be a `WorkspacePlatform` trait the workspace is generic over: `update` needs `before_layout` / `after_layout` (Windows: animation batch, `INITIAL_CONFIGURATION_LOADED`, title bars, stackbar; KDE: clamp, `scrolling_placed`), `place_window(window, rect)` (set_rect with the top flag against `set_position`), and `apply_wallpaper`; `remove_window` needs `on_window_removed` (Windows: `border_manager::delete_border`) and a decision on the return value (KDE's `Window` is what callers need: kfw returns it too); `hide` needs the scrolling step as a hook (`after_hide`) or moves into KDE only; `reintegrate_maximized_window` and `new_floating_window` need `unmaximize_window`, a no-op on Windows if its native maximize is not undone there (kfw to state which). 9. **(d)** Windows-only features become optional hooks or extension traits (`border_kinds_on_show`, `windows_on_show`, custom layouts) and KDE-only ones likewise (`adopt_maximized_window`, `prune_duplicate_windows`, `reap_invalid_windows`, floating-window focus helpers, `scrolling_placed`). Custom layouts exist in `komorebi-layouts` for both; KDE ignores them today (`Layout::Custom` arms): the question for the owner is whether KDE gains them (a behaviour change) or the shared code keeps `#[cfg]`-free arms that KDE never reaches. 10. **Monitor fields** (d): `Monitor

` with `#[serde(flatten)] platform: P` (decided: the schemars `required` order of the runtime notification and socket schemas changes, nothing checked in does). ## 8. Design: the platform hooks of a shared `Workspace` (not implemented) Status: design from kfw (Windows side), written against `wip-u-core` plus the revisions of `wip-u-kfw2` (items 1 to 7 of section 7 are done on Windows: `window_id`, `hide_window`/`restore_window`, `Window::focus`/`maximize`/`unmaximize` over `backend()`, `Option` lookups, `WindowDetails: From`, `remove_floating_window`, the shared error strings). Nothing below is code; no trait exists yet. ### 8.1 Shape `Workspace` lives in `komorebi-core`, like `Container`; `P::Window` is the `ContainerWindow` (`Copy`, with the `Id`). Windows and KDE implement `WorkspacePlatform` on a unit struct; the daemon uses the alias `type Workspace = komorebi_core::Workspace`. Every hook has a default that does nothing (or the generic behaviour), so a platform lists only its differences. The trait is a generic parameter, not `dyn`: the calls are in the layout hot path and the platform is fixed at compile time. All hooks take `&mut Workspace` or the relevant parts, never the `WindowManager`, so the layer below `window_manager` stays independent of it. The window-facing operations are NOT hooks: `focus`, `maximize`, `unmaximize`, `hide_window`, `restore_window`, `title`/`exe`/`class`/`path` (all `Option`) are methods the `ContainerWindow` bound (extended with `focus`, `maximize`, `unmaximize`, `title`, `exe`, `class`, `path`) requires, which both `Window` types now have under these names. Hooks are only for behaviour the shared code cannot express with window operations. ### 8.2 The (c) hooks | Where | Hook | Windows does | KDE does | Default | |---|---|---|---|---| | `update` start | `fn begin_update(&mut self_ws) -> Flow` | returns `Skip` until `INITIAL_CONFIGURATION_LOADED`; opens the `AnimationBatch` (a guard returned in `Flow::Run(guard)`, dropped at the end of `update`, so every move of the pass animates together) | nothing | `Run(())` | | `update` per layout | `fn adjust_layout(&self_ws, layouts: &mut [Rect], work_area: &Rect)` | nothing (the borders/padding are already applied by `calculate`) | clamps every layout to the work area, applies the percentage override | no-op | | `update`, a window gets its rect | `fn place_window(&self_ws, window: &P::Window, rect: &Rect, first: bool) -> eyre::Result<()>` | title-bar removal or restore (`REMOVE_TITLEBARS` and the per-window rules), `set_position(rect, top)` with the top flag set for the maximized and monocle windows, stackbar update for stacks | `set_rect`, `scrolling_placed` bookkeeping | `backend().set_rect` | | `update` end | `fn end_update(&mut self_ws)` | the scrolling `adopt_actual_widths` pass when animations are on and the user is not stepping a live resize (`live_resize::is_stepping`); stackbar redraw | `has_settled` | no-op | | `apply_wallpaper` | `fn apply_wallpaper(&self_ws, monitor: &MonitorId, wallpaper: &Wallpaper) -> eyre::Result<()>` | `WindowsApi::set_wallpaper` plus the palette theme built inline | asks the backend and calls `theme_manager::wallpaper_palette` | `Ok(())` (wallpaper is KDE/Windows only: the trait has no wallpaper operation, D36) | | `remove_window` | `fn on_window_removed(&mut self_ws, id: P::Window::Id)` | `border_manager::delete_border(id)` | nothing | no-op | | `remove_window` result | none: the shared version returns `eyre::Result>`; Windows callers that used `()` ignore it (KDE's callers need the window to unmaximize it) | | | | | `hide` | `fn after_hide(&mut self_ws)` | nothing | re-runs `update` for a scrolling workspace whose resize dimensions are set (the windows would otherwise stay where the hidden layout left them) | no-op | | `reintegrate_maximized_window`, `new_floating_window` | `fn unmaximize_window(w: &P::Window)` | none needed: `Window::unmaximize` is `ShowWindow(SW_NORMAL)`, and `update` re-places the window afterwards; KDE needs the compositor told. Statement for the owner: **Windows does not need to unmaximize there, and does not today** (`reintegrate_maximized_window` loads the focused window; `new_floating_window` does not unmaximize), so the hook is a no-op on Windows and the shared code calls it in both | `window.unmaximize()?` | no-op | `Flow` for `begin_update` is `Option`-like: `enum Flow { Skip, Run(G) }` with `type UpdateGuard` on the trait, `()` by default. `place_window` carries the whole per-window part of `update`; the shared `update` computes the layouts, walks containers, asks `place_window` for each window and nothing else. That is where most of the 161 changed lines of `update` go. Order of the calls inside `update`: `begin_update` -> (maximized/monocle/floating first: `place_window`) -> for each container: layouts from `adjust_layout`, `place_window` per visible window -> `end_update`. Both platforms already order it this way; the Windows `AnimationBatch` guard is dropped after `end_update` (so `end_update` still sees the batch open, as `adopt_actual_widths` does today). ### 8.3 The (d) features | Feature | Where | Becomes | |---|---|---| | `border_kinds_on_show`, `windows_on_show` (Windows) | `workspace.rs`, read by the border manager and `update` | an extension trait `WorkspaceWindowsExt for Workspace` in the Windows crate: they only read fields (`containers`, `monocle_container`, `maximized_window`, `floating_windows`), so they need those to be `pub`, which they are | | `hwnd_from_exe` (Windows) | `ContainerExt` today | stays an extension trait in the Windows crate (`ContainerExt`, `WorkspaceExt::window_id_from_exe`) | | custom layouts in `load_static_config` and `add_container_with_direction` (Windows) | `Layout::Custom` arms | stay in shared code behind the `Layout` enum, which already has `Custom` in `komorebi-layouts`; KDE's arms for it return the default layout. Open question for the owner (section 7 item 9): whether KDE should gain custom layouts (a KDE behaviour change) | | `promote_container`, `effective_layout_options` (Windows, 5 and 1 lines) | | shared as they are; KDE gains them unchanged (they are generic) | | `adopt_maximized_window`, `prune_duplicate_windows`, `reap_invalid_windows`, `percentage_within_horizontal_bounds`, the `scrolling_placed` field (KDE) | | a KDE-only extension trait `WorkspaceKdeExt`; `scrolling_placed` moves into `P::WorkspaceState`, an associated type with `Default + Serialize`-skipping (`#[serde(skip)]`) that the platform owns | | `focus_floating_window`, `focus_previous_floating_window`, `remove_floating_window`'s focus step (KDE) | | the focus step of `remove_floating_window` is a hook `fn after_floating_removed(&mut self_ws)` (no-op on Windows, which does not refocus), the two helpers become shared methods (they are generic) | | the Windows-only `Monitor` fields (`name`, `device_id`, `workspace_names`) | `monitor.rs` | section 7 item 10: `Monitor

` with `#[serde(flatten)] platform: P`; `P` is a `MonitorExtra`, `()` on KDE | ### 8.4 What this needs first 1. `ContainerWindow` (or a `WorkspaceWindow: ContainerWindow` supertrait) grows `focus`, `maximize`, `unmaximize`, `title`, `exe`, `class`, `path`. Both `Window`s have them with the same signatures after the revisions above (`focus` returns `eyre::Result<()>`, the others `()`/`Option`). 2. `WindowDetails` and `WindowKind`-keyed border data are already plain data; `Wallpaper` and `WorkspaceLayer` are in `komorebi-protocol`. 3. The statics that `update` reads (`REMOVE_TITLEBARS`, `INITIAL_CONFIGURATION_LOADED`, the padding and border globals) are read inside the hooks, not in shared code. `WorkspaceGlobals` (padding, border offset/width, work-area offset) is plain data and is the only global state the shared `Workspace` takes, through `update_globals` as today. 4. `border_manager` and `stackbar_manager` stay in the Windows crate and are reached only from `place_window`, `on_window_removed` and `end_update`. ### 8.5 Order of work and gates (1) the `ContainerWindow` extension (pure), (2) move the identical and (b) functions of `workspace.rs` into the crate generic over the window, keeping the hooks as direct calls under `#[cfg]` for one revision each (pure move), (3) introduce `WorkspacePlatform` with the defaults and implement it for both platforms (behaviour-preserving on both), one hook per revision in the order of the table: `on_window_removed`, `apply_wallpaper`, `place_window`, `begin_update`/`end_update`, `after_hide`, `unmaximize_window`. Gates: Windows `commands`, `workspaces`, `stacking`, `scrolling`, `resize`, `borders`, `animation` scenarios per hook; KDE fast tier and VM for `adjust_layout`, `after_hide`, `unmaximize_window`. ## 9. Review of section 8 against KDE's code, and the order that follows from it Read against `komorebi-for-kde/komorebi/src/workspace.rs` (`update`, `hide`, `remove_window`) and the Windows `update`. Section 8 holds, with these corrections and additions; none changes the idea, but three change a signature. 1. **`begin_update` must be fallible and may change the workspace.** KDE's `update` starts with `self.prune_duplicate_windows()?` and then clamps `focused_container_idx` to the container count. Both are before anything is laid out, so they belong in `begin_update(&mut ws) -> eyre::Result>`, not in the shared `update`. (Windows has no such step.) 2. **`end_update` must say whether to run the pass again.** The Windows `update` is a loop: after the placement pass, `if pass == 0 && is_scrolling && !animations_enabled() && adopt_actual_widths(..) { pass += 1; continue; }`. A hook that returns nothing cannot express that; it must be `end_update(&mut ws) -> eyre::Result` with `enum Pass { Done, Again }`, and the shared `update` loops while it answers `Again` (at most one repeat, as Windows does; KDE always says `Done`). 3. **There is one more condition read in the middle of `update`, a hook of its own.** Where the scrolling layout decides whether the pass is animated, Windows reads `crate::scrolling::animations_enabled() && !live_resize::is_stepping()` and KDE reads `scrolling_placed.is_some_and(Placed::has_settled) && !live_resize_in_progress()`. Section 8 files `has_settled` under `end_update`, but KDE reads it before the layouts are computed. Add `fn scrolling_in_motion(&ws) -> bool` (default `false`); `scrolling_placed` is set at the end of KDE's pass, in `end_update`. 4. **Error policy of `place_window` differs** and the hook returns `Result` to carry both: Windows propagates a failed `set_rect` with `?`, KDE logs it (`tracing::warn!("failed to set window position")`) and goes on, while `center(..)?` in the percentage override does propagate. The KDE `place_window` swallows exactly the `set_position` error; the shared caller always uses `?`. 5. **`adjust_layout` is not enough for Windows' stackbar**: it subtracts the stackbar height from the layout of a container with more than one window (`should_have_stackbar(window_count)`). That is a per-container adjustment, so `adjust_layout` is called per container with its index, `adjust_layout(&ws, idx: usize, layout: &mut Rect, work_area: &Rect)`, not once with the slice; KDE's clamp is per layout as well and only when the workspace is not scrolling. 6. **KDE wraps the placement in a closure** (`let result = (|| { .. })(); result?;`) with nothing between the closure and the `?`, so it is equivalent to no closure (class (a) drift: the closure only keeps the `?`s of the placement local). The shared `update` needs no such shape; a Windows `AnimationBatch` guard drops on any exit and is compatible with either. 7. **Orphan rule:** a method only one platform has cannot be an inherent method of the generic `Workspace

` in the crate. `adopt_maximized_window`, `prune_duplicate_windows` and `reap_invalid_windows` (KDE), `border_kinds_on_show`, `windows_on_show` (Windows) become extension traits, and each file that calls them imports the trait (6 call sites of the KDE three outside `workspace.rs` today). The extension traits are the price of keeping the `Workspace` fields `pub`. 8. **`WorkspaceConfig` and `Wallpaper` are already shared** (`komorebi-config`, `komorebi-protocol`), so `load_static_config` is not blocked by the daemon's `static_config.rs`; it can move into the crate (the Windows custom-layout arms stay in it). 9. **`Monitor` cannot move before `Workspace`.** The shared Monitor code uses about 25 members of its workspace (`layout`, `globals`, `tile`, `layer`, `containers_mut`, `add_container_to_back`, `insert_container_at_idx`, `floating_windows_mut`, `reintegrate_monocle_container`, ...). Once `Workspace

` exists those are inherent members and `Monitor

` takes `Ring>` directly; before that, `Monitor` would need the 25-member trait that was ruled out. The order is therefore: `WorkspaceGlobals` (plain data), `Workspace

`, `Monitor

`; the request to do `Monitor` first is only possible after the first. 10. **Decision for the owner, `visible-windows`.** Windows' `visible_window_details` used `try_into` and skipped a window whose fields could not be read; the shared `visible_window_details` uses `into`, so after kfw's revision 4 Windows lists such a window with empty strings in `komorebic visible-windows`. The shared code depends on the infallible form; the alternative is a `WindowDetails: TryFrom` shared with a skip for `Err`, which KDE (always `Ok`) would not notice. Recommended: keep `into`. ### Order of the code that remains (all gated like section 8.5) 1. `WorkspaceGlobals` into `komorebi-core` (identical plain data; this stack does it). 2. `Workspace

` struct and the 50 identical functions, `P::Window: WorkspaceWindow` (`ContainerWindow` plus `focus`, `maximize`, `unmaximize`, `title`, `exe`, `class`, `path`, `rect`, `center`, `set_position`), the differing functions as hooks or extension traits as in sections 8 and 9. About 2000 lines move; every call site that used an inherent method of a one-platform function imports the extension trait. This is one revision per cluster of functions, and it is the first step that cannot be verified on Windows without the e2e scenarios. 3. `Monitor

` with `#[serde(flatten)] platform: P`. ## 10. Status of the Workspace move (after D-coord-5) Landed on `wip-u-core`: `WorkspaceGlobals` in the crate; the nine id/focus/cursor functions of `workspace.rs` have the same text on both sides (`PlatformWindowId`, `window.id()`, `foreground_window_id()`, `cursor_position()` as per-daemon helpers in `window.rs`, which are the future `WorkspacePlatform::foreground_window` and `cursor_pos` hooks). Measured now (per-function, same method as section 7): 58 functions of `workspace.rs` are identical on both sides, about 530 lines outside the tests, and they use only the `Container`, `Ring`, layout and `ContainerWindow` API (the one window call is `window.exe()`). The only identical function that calls a differing one is `new_container_for_window`, which calls `resolve_placement_index`. So the first move is: the struct, the 58 identical functions and `resolve_placement_index`, with every other differing function an extension trait per daemon. What still differs, function by function, and what removes the difference: | function | differs by | converge step | |---|---|---| | `resolve_placement_index` (25) | `should_act` takes `&[class]` on KDE and `&class` on Windows; `path` is a `PathBuf` on KDE and a `String` on Windows | one `should_act` signature and one `path()` type on both sides (KDE's slice form, `String`) | | `default` (31) | field order; Windows seeds `workspace_padding`/`container_padding` from the `DEFAULT_*` statics; KDE has `scrolling_placed` | `P::WorkspaceState` for `scrolling_placed`; the padding seed as a `WorkspacePlatform::default_padding` hook | | `new_maximized_window`, `new_floating_window`, `reintegrate_maximized_window`, `remove_floating_window` | the `unmaximize_window` and `after_floating_removed` hooks of section 8; Windows' `load_focused_window` after reintegration | the hooks | | `update`, `apply_wallpaper`, `hide`, `restore`, `remove_window` | the (c) hooks of sections 8 and 9 | the hooks, one per revision | | `load_static_config`, `effective_layout_options`, `promote_container` | the Windows `Layout::Custom` arms | decision of the owner: KDE gains the arms (they are unreachable on KDE today: it cannot load a custom layout) or they stay Windows-only through a hook | | `visible_window_details` | `into` on both after the Option change; KDE iterates by reference | none beyond a text pass | | `border_kinds_on_show`, `windows_on_show`, `hwnd_from_exe` (Windows); `adopt_maximized_window`, `prune_duplicate_windows`, `reap_invalid_windows`, `focus_*_floating_window` (KDE) | one-sided | extension traits (section 9, item 7) | | the unit tests | use the fake backend (KDE) or Win32 (Windows) | stay in each daemon over the alias | Not done: the move itself and the hook revisions; each needs `P::Window` to carry `title`, `exe`, `class`, `path`, `focus`, `maximize`, `unmaximize`, `rect` and `set_position` on `ContainerWindow` (or a `WorkspaceWindow` supertrait) before the first generic function compiles, which is itself a revision with a stub window in the crate's tests. ## 11. The Workspace move: what landed and where the sharing stops (semantic differences) Landed on `wip-u-core`: `komorebi-core` holds `Workspace` with its 50 or so shared functions (container and focus operations, placement, the resize constraints, the window lookups, `is_scrolling`, the floating helpers), `WorkspaceGlobals`, `WorkspaceLayer`, `WorkspaceWindowLocation`, `WorkspaceWindow`, and the hooks `default_padding`, `foreground_window_id`, `cursor_position`, `should_act_window(_individual)`, the three scrolling lookups and `after_floating_removed`. KDE (`KdePlatform`, state `scrolling_placed`) and Windows (`WindowsPlatform`, no state) both use it through `type Workspace = komorebi_core::Workspace<..>`; every other method of the old impl is a method of `WorkspaceExt` (and `WorkspaceScrolling` for the glue of `scrolling.rs`), unchanged in body. The functions left in the extension traits cannot be shared by a converge that keeps behaviour. Read side by side they differ in what they DO, not in how they are written: | function | the difference in behaviour | |---|---| | `remove_window` | Windows deletes the window's border, removes a floating window itself (`retain`) and returns `()`; KDE has no floating branch (its callers use `remove_floating_window`) and returns the removed `Window`. They test the maximized and the monocle window in the opposite order, and Windows ignores the result of `unmaximize`. | | `restore` | with a monocle container that has no focused window, Windows carries on to restore the containers, the floating windows and the maximized window; KDE restores the monocle container, applies the wallpaper and returns. | | `hide` | KDE tiles a scrolling workspace again, without its resize adjustments, before hiding (the `after_hide` hook of section 8); Windows does not. This one is a hook and can be shared once that hook exists. | | `new_maximized_window` | Windows takes the floating window at the focus index of the ring, KDE the one that has the foreground (`remove_focused_floating_window`); KDE propagates a failed `maximize`, Windows ignores it. | | `reintegrate_maximized_window` | KDE unmaximizes the window and adds it with `add_window` (which hides the others of the container); Windows pushes it back and calls `load_focused_window`. | | `new_floating_window` | KDE unmaximizes the window first (a hook, `unmaximize_window`, a no-op on Windows by section 8). | | `apply_wallpaper` | Windows sets the wallpaper through Win32, builds the palette itself, sends the theme over the daemon's socket and passes the stackbar colours of the wallpaper's theme options; KDE asks the backend, uses `theme_manager::wallpaper_palette` (another cache) and passes `None` for the stackbar colours. | | `update` | the (c) list of section 7: title bars, stackbars, the animation batch, `adopt_actual_widths` and its second pass on Windows; the work-area clamp, the percentage override and `scrolling_placed` on KDE; and the placement error policy of section 9. | | `load_static_config`, `effective_layout_options`, `promote_container` | the Windows `Layout::Custom` arms (D-coord-6: a Windows hook). | Making any of the first five identical means choosing one platform's behaviour for the other, which is a decision about the daemon, not a refactor. Hooks cover `hide`, `new_floating_window` and the custom layouts without changing either daemon; `update` and `apply_wallpaper` need a hook for each platform-specific step plus an agreed shared skeleton, which is a design the owner has to confirm against the Windows e2e (the first is the lengthiest function in the file). Until then they stay the daemons' own methods. `Monitor

` is not started. After this move its shared half is the functions that touch only the core `Workspace` API (`add_container`, `remove_workspace_by_idx`, `ensure_workspace_count`, `remove_workspaces`, `new_workspace_idx`, `focused_workspace_layout`, `focused_workspace_name`); the platform half is the constructors (the Windows `name`, `device_id` and `workspace_names`), `load_focused_workspace`, the two globals updates (`BORDER_ENABLED` against `BORDER_DRAWN`), `focus_workspace` (the names), `move_container_to_workspace` (the foreground window) and the Windows-only custom-layout arms of `add_container_with_direction`. ## 12. Proposal: the shared `update` skeleton (not implemented) Written from the two bodies as they are in `wip-u-core` after the Workspace move (the Windows one is `WorkspaceExt::update` in `komorebi/src/workspace.rs`, the KDE one in `komorebi-for-kde/komorebi/src/workspace.rs`). The unified diff of the two, Windows against KDE, is at the end of this section: every hunk of it is either a line of the skeleton below that both daemons have (so no hunk), or the body of one of the hooks. Differences that are only the order of two `let`s (`window_based_work_area_offset*`, `managed_maximized_window`) have no effect and are dropped. ### The skeleton (what moves into `komorebi-core`) ```rust pub fn update(&mut self) -> eyre::Result<()> { let _guard = match P::begin_update(self)? { // Windows: Skip until INITIAL_CONFIGURATION_LOADED, Flow::Skip => return Ok(()), // else Run(AnimationBatch::begin()); Flow::Run(guard) => guard, // KDE: prune_duplicate_windows()?, Run(()) }; let focus_before = self.focused_container_idx(); self.containers_mut().retain(|c| c.is_preselect() || !c.windows().is_empty()); P::containers_pruned(self, focus_before); // KDE: if focus_before >= len, focus len-1 (saturating); // Windows: nothing (it must NOT clamp) // paddings, work area, work-area offset rules, window-based offset, layout rules, // window-container-behaviour rules: identical on both daemons, move as they are if self.tile { if let Some(container) = &mut self.monocle_container { // identical shape if let Some(window) = container.focused_window_mut() { /* the three add_padding calls */ P::place_monocle(window, &adjusted_work_area)?; // Windows: set_position(rect, top = true) } // KDE: set_position(rect) } else if let Some(window) = &mut self.maximized_window { P::maximize_in_layout(window)?; // Windows: window.maximize() (result ignored } else if !self.containers().is_empty() { // inside), KDE: window.maximize()? let around_window = container_padding + border_offset + border_width; if self.is_scrolling() && P::adopt_widths_before_layout(self) { self.adopt_actual_widths(around_window, adjusted_work_area.right); } // Windows: animations_enabled() && !is_stepping() // KDE: scrolling_placed.has_settled() && !live_resize_in_progress() let mut pass = 0; loop { let effective_layout_options = self.effective_layout_options(); let mut hidden = vec![]; let mut layouts = if self.is_scrolling() { let others = scrolling::other_monitors(&self.globals.work_area, &P::monitor_rects()); /* layout_scrolling + scrolling::rest, as both have it */ } else { /* layout.as_boxed_arrangement().calculate(..), as both have it */ }; for (i, container) in self.containers_mut().iter_mut().enumerate() { if let Some(layout) = layouts.get_mut(i) { P::adjust_layout(container, i, layout, &Placement { adjusted_work_area, work_area, container_padding, border_offset, border_width, is_scrolling }); // Windows: add_padding(border_offset), add_padding(border_width), then the stackbar height // KDE: the clamp to the work area when not scrolling, then the two add_padding calls P::place_container(container, i, layout, hidden.get(i).copied().unwrap_or_default(), &Placement { .. })?; // Windows: per window: title bar rules, unminimize after toggle-maximize, hide or // (restore if should_restore_when_placed, backend().set_rect) // KDE: hide all if hidden, restore the front if should_restore_when_placed, then per window // the percentage override (center) or set_position, logging a failed set_position } } self.latest_layout = layouts; match P::end_update(self, pass, around_window, adjusted_work_area.right)? { Pass::Again => { pass += 1; continue } // Windows: pass == 0 && is_scrolling && Pass::Done => break, // !animations_enabled() && adopt_actual_widths(..) } // KDE: records scrolling_placed, always Done } } } /* the resize_dimensions resize: identical */ Ok(()) } ``` ### The hooks, with the exact bodies the daemons keep | hook | Windows body | KDE body | |---|---|---| | `begin_update(ws) -> Result>` | `if !INITIAL_CONFIGURATION_LOADED.load(SeqCst) { return Ok(Flow::Skip) }`; `Ok(Flow::Run(AnimationBatch::begin()))` | `ws.prune_duplicate_windows()?; Ok(Flow::Run(()))` | | `containers_pruned(ws, focus_before)` | nothing | `if focus_before >= ws.containers().len() { ws.focus_container(focus_before.saturating_sub(1)); }` | | `place_monocle(window, rect)` | `window.set_position(rect, true)?` | `window.set_position(rect)?` | | `maximize_in_layout(window)` | `window.maximize()` (it is `backend().maximize` ignoring the result today) then `Ok(())` | `window.maximize()?` | | `adopt_widths_before_layout(ws) -> bool` | `crate::scrolling::animations_enabled() && !crate::live_resize::is_stepping()` | `ws.state.scrolling_placed.as_ref().is_some_and(Placed::has_settled) && !crate::window::live_resize_in_progress()` | | `monitor_rects()` | `WindowsApi::monitor_rects()` | `backend().monitors().map(\|m\| m.iter().map(\|m\| m.size).collect()).unwrap_or_default()` | | `adjust_layout(container, i, layout, placement)` | the two `add_padding` calls, then `if stackbar_manager::should_have_stackbar(window_count) { layout.top += tab_height + container_padding; layout.bottom -= tab_height + container_padding }` | `if !is_scrolling { clamp left/right/top/bottom to adjusted_work_area }`, then the two `add_padding` calls | | `place_container(container, i, layout, hidden, placement)` | the loop over `container.windows()` in the diff below, with `managed_maximized_window` read from the workspace | the hide / restore-front / per-window loop in the diff below | | `end_update(ws, pass, around_window, right) -> Result` | `if pass == 0 && ws.is_scrolling() && !animations_enabled() && ws.adopt_actual_widths(around_window, right) { Pass::Again } else { Pass::Done }` | `if ws.is_scrolling() { ws.state.scrolling_placed = Some(Placed::new(..)) } Ok(Pass::Done)` | Constraints that make this safe to implement, all checked against the diff: - The order of effects is the same on both daemons: begin, prune, work area, layout rules, then the tile branch. KDE's closure wrapper (`let result = (|| { .. })(); result?;`) has nothing between the closure and the `?`, so it does not need a counterpart (section 9, item 6). - `Windows` must not get KDE's focus clamp, and KDE must not get the `INITIAL_CONFIGURATION_LOADED` check; both are hooks that the other daemon leaves empty. - `place_container` receives `hidden` already looked up for the container; KDE's `continue` after hiding is the end of that hook. - The Windows `AnimationBatch` guard must live until the end of `update`, which `_guard` does. - Tests to run before and after: the KDE `update` tests of `workspace.rs` and `scrolling.rs` (`update_puts_each_window_on_its_tile`, the scrolling tests) and, for Windows, the e2e sweeps of commands, stacking, scrolling, resize, borders and animation named in the Windows revision of the Workspace move; the e2e must run before the Windows revision of this step is accepted. ### The bodies, as a diff (Windows `-`, KDE `+`) ```diff @@ -1,15 +1,15 @@ fn update(&mut self) -> eyre::Result<()> { - if !INITIAL_CONFIGURATION_LOADED.load(Ordering::SeqCst) { - return Ok(()); - } - - // every window that moves in this pass is animated together - let _animations = AnimationBatch::begin(); - // make sure we are never holding on to empty containers + self.prune_duplicate_windows()?; + let focused_container_idx = self.focused_container_idx(); self.containers_mut() .retain(|c| c.is_preselect() || !c.windows().is_empty()); + // make sure we are never focused on a no longer existent final empty container + if focused_container_idx >= self.containers().len() { + self.focus_container(focused_container_idx.saturating_sub(1)); + } + let container_padding = self .container_padding .or(self.globals.container_padding) @@ -21,8 +21,6 @@ let border_width = self.globals.border_width; let border_offset = self.globals.border_offset; let work_area = self.globals.work_area; - let window_based_work_area_offset = self.globals.window_based_work_area_offset; - let window_based_work_area_offset_limit = self.globals.window_based_work_area_offset_limit; let mut rules_work_area_offset = None; if !self.work_area_offset_rules.is_empty() { @@ -42,6 +40,8 @@ let work_area_offset = rules_work_area_offset .or(self.work_area_offset) .or(self.globals.work_area_offset); + let window_based_work_area_offset = self.globals.window_based_work_area_offset; + let window_based_work_area_offset_limit = self.globals.window_based_work_area_offset_limit; let mut adjusted_work_area = work_area_offset.map_or_else( || work_area, @@ -55,6 +55,7 @@ with_offset }, ); + if (self.containers().len() <= window_based_work_area_offset_limit as usize || self.monocle_container.is_some() && window_based_work_area_offset_limit > 0) && self.apply_window_based_work_area_offset @@ -102,33 +103,37 @@ self.window_container_behaviour = updated_behaviour; } - let managed_maximized_window = self.maximized_window.is_some(); - if self.tile { - if let Some(container) = &mut self.monocle_container { - if let Some(window) = container.focused_window_mut() { - adjusted_work_area.add_padding(container_padding); - adjusted_work_area.add_padding(border_offset); - adjusted_work_area.add_padding(border_width); - window.set_position(&adjusted_work_area, true)?; - }; - } else if let Some(window) = &mut self.maximized_window { - window.maximize(); - } else if !self.containers().is_empty() { - // windows that were not what they were told to be since the last layout, which - // is something that animations only show once they are done - let around_window = container_padding + border_offset + border_width; - // neighbours of a window that is dragged are not yet the size they are being - // made while it is followed: that is not what their columns are to be - if self.is_scrolling() - && crate::scrolling::animations_enabled() - && !crate::live_resize::is_stepping() - { - self.adopt_actual_widths(around_window, adjusted_work_area.right); - } + // the backend batches the commands this produces and sends them together + let result = (|| -> eyre::Result<()> { + if let Some(container) = self.monocle_container.as_mut() { + if let Some(window) = container.focused_window_mut() { + adjusted_work_area.add_padding(container_padding); + adjusted_work_area.add_padding(border_offset); + adjusted_work_area.add_padding(border_width); + window.set_position(&adjusted_work_area)?; + }; + } else if let Some(window) = self.maximized_window { + // the compositor's own maximize, which is the work area and not the padded one + window.maximize()?; + } else if !self.containers().is_empty() { + let is_scrolling = self.is_scrolling(); + let around_window = container_padding + border_offset + border_width; + + // a window that would not get as narrow as its column was learned of once the + // windows have had time to draw at the sizes that they were sent, and not + // while one is being dragged, whose neighbours are on their way + if is_scrolling + && self + .state + .scrolling_placed + .as_ref() + .is_some_and(crate::scrolling::Placed::has_settled) + && !crate::window::live_resize_in_progress() + { + self.adopt_actual_widths(around_window, adjusted_work_area.right); + } - let mut pass = 0; - loop { let effective_layout_options = self.effective_layout_options(); tracing::debug!( @@ -138,12 +143,15 @@ self.layout_options, self.layout_options_rules.len(), ); + // the containers that rest where another monitor is seen from, which are hidden let mut hidden = vec![]; - let mut layouts = if self.is_scrolling() { - let others = crate::scrolling::other_monitors( - &self.globals.work_area, - &WindowsApi::monitor_rects(), - ); + let mut layouts = if is_scrolling { + let monitors: Vec = backend() + .monitors() + .map(|monitors| monitors.iter().map(|monitor| monitor.size).collect()) + .unwrap_or_default(); + let others = + crate::scrolling::other_monitors(&self.globals.work_area, &monitors); self.layout_scrolling(&adjusted_work_area, container_padding) .into_iter() @@ -173,69 +181,87 @@ ) }; - let should_remove_titlebars = REMOVE_TITLEBARS.load(Ordering::SeqCst); - let no_titlebar = NO_TITLEBAR.lock().clone(); - let regex_identifiers = REGEX_IDENTIFIERS.lock().clone(); + let resize_dimensions_is_empty = self.resize_dimensions.is_empty(); let containers = self.containers_mut(); for (i, container) in containers.iter_mut().enumerate() { - let window_count = container.windows().len(); - if let Some(layout) = layouts.get_mut(i) { + // The scrolling layout is a strip that is meant to run past the edges of + // the work area, and says itself where each window rests when it is out + // of view (KWin does not constrain where a window is put). Everything + // else is clamped to the work area so it cannot bleed onto an adjacent + // monitor. + if !is_scrolling { + // horizontal + if layout.left < adjusted_work_area.left { + layout.right -= adjusted_work_area.left - layout.left; + layout.left = adjusted_work_area.left; + } + let max_right_edge = + adjusted_work_area.left + adjusted_work_area.right; + let current_right_edge = layout.left + layout.right; + if current_right_edge > max_right_edge { + layout.right = max_right_edge - layout.left; + } + + // vertical + if layout.top < adjusted_work_area.top { + layout.bottom -= adjusted_work_area.top - layout.top; + layout.top = adjusted_work_area.top; + } + let max_bottom_edge = + adjusted_work_area.top + adjusted_work_area.bottom; + let current_bottom_edge = layout.top + layout.bottom; + if current_bottom_edge > max_bottom_edge { + layout.bottom = max_bottom_edge - layout.top; + } + } + layout.add_padding(border_offset); layout.add_padding(border_width); - if stackbar_manager::should_have_stackbar(window_count) { - let tab_height = STACKBAR_TAB_HEIGHT.load(Ordering::SeqCst); - let total_height = tab_height + container_padding; - - layout.top += total_height; - layout.bottom -= total_height; + if hidden.get(i).copied().unwrap_or_default() { + // it is seen on another monitor wherever it rests here + for window in container.windows() { + window.hide(); + } + continue; } - for window in container.windows() { - if container - .focused_window() - .is_some_and(|w| w.hwnd == window.hwnd) - { - let should_remove_titlebar_for_window = should_act( - &window.title().unwrap_or_default(), - &window.exe().unwrap_or_default(), - &window.class().unwrap_or_default(), - &window.path().unwrap_or_default(), - &no_titlebar, - ®ex_identifiers, + // a window that was hidden for resting past an edge with another + // monitor next to it is shown before it is put where the strip says + let front = container.focused_window().map(|window| window.id); + let restore = is_scrolling + && front.is_some_and(|front| { + crate::scrolling::should_restore_when_placed( + front, + Some(front), + &HIDDEN_WINDOWS.lock(), ) - .is_some(); - - if should_remove_titlebars && should_remove_titlebar_for_window - { - window.remove_title_bar()?; - } else if should_remove_titlebar_for_window { - window.add_title_bar()?; - } - - // If a window has been unmaximized via toggle-maximize, this block - // will make sure that it is unmaximized via restore_window - if window.is_maximized() && !managed_maximized_window { - let _ = - backend().unminimize(WindowId::from_hwnd(window.hwnd)); - } - } - if hidden.get(i).copied().unwrap_or_default() { - // it is seen on another monitor wherever it rests here - window.hide_window(); - } else { - if crate::scrolling::should_restore_when_placed( - window.hwnd, - container.focused_window().map(|w| w.hwnd), - &HIDDEN_HWNDS.lock(), - ) { - window.restore_window(); - } + }); + if restore && let Some(window) = container.focused_window() { + window.restore(); + } - backend().set_rect(WindowId::from_hwnd(window.hwnd), layout)?; + for window in container.windows_mut() { + let current_rect = window.rect().unwrap_or_default(); + let current_percentage = + work_area.percentage_within_horizontal_bounds(¤t_rect); + let proposed_percentage = + work_area.percentage_within_horizontal_bounds(layout); + + // the strip puts a window where it goes, however far + let percentage_override = !is_scrolling + && proposed_percentage == 0.0 + && current_percentage > 0.1 + && current_percentage < 100.0 + && resize_dimensions_is_empty; + + if percentage_override { + window.center(&work_area, false)?; + } else if let Err(error) = window.set_position(layout) { + tracing::warn!("failed to set window position: {error}") } } } @@ -243,22 +269,19 @@ self.latest_layout = layouts; - // with animations off every window that changed width has been asked what it - // is by now, so a window that refused is known, and everything after it is - // placed again with room for it. Once is enough: a layout is never made to - // chase the sizes that are read back this time round. - if pass == 0 - && self.is_scrolling() - && !crate::scrolling::animations_enabled() - && self.adopt_actual_widths(around_window, adjusted_work_area.right) - { - pass += 1; - continue; + if is_scrolling { + self.state.scrolling_placed = Some(crate::scrolling::Placed::new( + self.containers() + .iter() + .zip(self.latest_layout.iter()) + .map(|(container, layout)| (container.id.clone(), layout.right)), + )); } - - break; } - } + Ok(()) + })(); + + result?; } // Always make sure that the length of the resize dimensions vec is the same as the ``` ## 13. Monitor

and the three hook-shared functions (landed) `komorebi-core` has `Monitor`: `MonitorPlatform` extends `WorkspacePlatform` with `Id`, `SerialNumber` (`Default`, so a missing `serial_number_id` reads as the platform's default), `Extra` (flattened into the JSON; `NoExtra` on KDE, `WindowsMonitorExtra` {name, device_id, workspace_names} on Windows), `serialize_id`/`deserialize_id` (each daemon keeps writing its id as the plain number it always did), and `known_workspace_name`. Eight functions are shared (`focused_workspace_layout`, `focused_workspace_name`, `new_workspace_idx`, `ensure_workspace_count`, `remove_workspaces`, `remove_workspace_by_idx`, `add_container`, `focus_workspace`); the rest of the old impl (the constructors, `load_focused_workspace`, the globals updates, `update_focused_workspace`, `move_container_to_workspace`, `add_container_with_direction`, `assign_windows_to_workspaces`) is the daemons' `MonitorExt`. The two globals updates are NOT shared: KDE's `update_workspaces_globals` hard-codes no border while its `update_workspace_globals` honours `BORDER_DRAWN`, so one hook would change KDE. Facts the tests pin: the JSON of a monitor with flat extra fields and the id as a number; a flattened map reads its keys as strings, so integer-keyed extra fields need a `deserialize_with`; the schemars `required` order of the runtime schema has the flattened fields last (D-coord-4, accepted). `promote_container`, `effective_layout_options`, `new_floating_window` and `hide` are shared too, with the hooks `primary_container_idx` (Windows custom layouts), `unmaximize_window` (KDE) and `before_hide_containers` (the KDE scrolling prelude). `effective_layout_options` needed no hook: its Windows `Custom` arm is the crate's fallback arm, unreachable on KDE. ## 14. macOS against the shared model (classification, step 11(iii)) Method as in section 7: each file is cut into functions (tests apart, comments and blank lines dropped) and each macOS function is compared with its namesake in `crates/komorebi-core` (or, for what is an extension trait, in the KDE `WorkspaceExt`/`MonitorExt`). Counts are of functions; the number in brackets is the changed lines (a changed line counts once on each side). Measured at `wip-u-integ2` (c4bb995c); the scripts are throw-away (`split.sh` in the kfm scratchpad). macOS is closer to KDE than to Windows: it has the same `Ring`/`Container` shapes, the same `u32` id (`CGWindowID`), no maximize and no opacity, and its `Workspace` was copied from the KDE one (the `scrolling` glue, `effective_layout_options`, the floating helpers). What it does not share is **how a window is hidden**. ### `container.rs` (15 functions) | class | functions | |---|---| | identical (5) | `focus_window`, `is_preselect`, `locked`, `set_locked`, `preselect` | | same behaviour, different text (6; id type and `window.id` against `window.id()`) | `contains_window`, `idx_for_window`, `idx_from_exe`, `default`, `remove_focused_window`, `remove_window_by_idx` | | real platform difference (4) | `add_window`, `load_focused_window`, `hide`, `restore`: macOS takes a `WindowHidingPosition` (and for `hide` the display bounds), returns `Result`, calls `Window::hide/restore` that make Accessibility calls, and skips the hiding when `cfg!(test)` | | one-sided | none | ### `workspace.rs` (65 functions, tests apart) | class | functions | |---|---| | identical (11, 0 changed lines) | `cancel_preselect`, `cycle_monocle_container`, `fmt`, `focus_container`, `focus_floating_window`, `focus_previous_container`, `focus_previous_floating_window`, `is_empty`, `new_idx_for_cycle_direction`, `preselect_container_idx`, `swap_containers` | | same behaviour, different text (26): only `Container` against `Container` and `window_id: u32` against `PlatformWindowId

` | `add_container_to_back/front`, `container_for_window`, `container_idx_for_window`, `contains_managed_window`, `contains_window`, the eleven `enforce_*`, `focus_first/last_container`, `insert_container_at_idx`, `location_from_exe`, `new_container_for_floating_window`, `new_idx_for_direction`, `remove_container`, `remove_focused_container`, `resolve_threshold_match`, `sorted_layout_options_rules`, `visible_windows` | | differs only through a hook that exists (8) | `container_idx_from_current_point` (`cursor_position`), `is_focused_window_monocle_or_maximized` and `remove_focused_floating_window` (`foreground_window_id`), `remove_floating_window` (`after_floating_removed`), `remove_container_by_idx` (`note_scrolling_departure`), `promote_container` (`primary_container_idx`), `default` (`default_padding`, `State`), `effective_layout_options` (visibility only) | | differs by the hiding position (8) | `hide`, `move_window_to_container`, `new_floating_window`, `new_container_for_focused_window`, `new_container_for_window`, `reintegrate_monocle_container`, `new_monocle_container`, `focus_container_by_window`: each passes `self.globals.window_hiding_position` (and the display bounds) to a container call that returns `Result`; the shared text calls the no-argument infallible forms | | real platform difference, in the macOS extension trait (4) | `update` (136, CoreGraphics work area clamp, `set_scrolling_columns`, animator), `apply_wallpaper` (97), `restore` (a `Result`, AX), `remove_window` (51) | | text only, in the extension trait (3) | `load_static_config` (9), `prune_duplicate_windows` (4), `visible_window_details` (4) | | one-sided macOS (5) | `calculate_layouts`, `clamp_to_work_area`, `reap_invalid_windows_for_application`, `restore_windows`, `set_scrolling_columns` | ### `monitor.rs` (21 functions) | class | functions | |---|---| | identical (5) | `ensure_workspace_count`, `focused_workspace_layout`, `focused_workspace_name`, `new_workspace_idx`, `from` | | same behaviour, different text (4) | `add_container`, `remove_workspace_by_idx`, `remove_workspaces`, `focus_workspace` | | in the macOS extension trait, differs from KDE's (8) | `new`, `placeholder`, `load_focused_workspace`, `update_focused_workspace`, `update_workspace_globals`, `update_workspaces_globals`, `move_container_to_workspace`, `add_container_with_direction` (the monitor also carries `window_hiding_position`) | | one-sided macOS (4) | `hide_unfocused_workspaces`, `move_container_to_workspace1`, `restore_focused_workspace`, `update_all_workspace_globals` | ### What decides the order One difference runs through all three files: **the hiding position and the fallible hide**. Shared `Container::hide/add_window/load_focused_window` and the 8 workspace functions above call `ContainerWindow::hide_window()` and friends with no argument and no result. macOS needs, per call, the monitor's corner, the display bounds of the monitor, and the error (a failed hide is logged for floating windows and propagated for tiles). Making the shared text carry that is a change in the shared crate that touches KDE and Windows (an associated `HideSpot` type, `()` for both, taken from the workspace globals); keeping it out of the shared text means the 8 workspace functions stay macOS's own. The container is the one layer that can move without deciding it, because its four hiding functions can stay in a macOS extension trait. ### Result of the macOS move so far, and where it stops (kfm) Done: `ContainerWindow` is `Clone` (the macOS `Window` holds retained references and cannot be `Copy`; KDE and Windows are unchanged), and the macOS `Container` is `komorebi_core::Container` with `ContainerExt` for the four hiding functions (`add_window_at`, `load_focused_window_at`, `hide_on`, `restore_focused_window`). Stopped before `Workspace`, on one decision that the macOS tests cannot make: - Eight shared workspace functions (`hide`, `move_window_to_container`, `new_floating_window`, `new_container_for_focused_window`, `new_container_for_window`, `reintegrate_monocle_container`, `new_monocle_container`, `focus_container_by_window`) call the no-argument, infallible `Container::load_focused_window` / `add_window` / `hide` and `ContainerWindow::hide_window`. macOS passes the monitor's corner (and the display bounds) and propagates the error. Four of the eight have the same signature as macOS's own, so after the switch the shared body would be called silently, hiding every window in the default corner: that is a behaviour change, not a type error, and the type-check would not catch it. - `ContainerWindow::hide_window`, `restore_window`, `hide_stacked`, `restore_stacked` on macOS can only use the default corner, because a window does not know its monitor. Proposed core change (not made: it touches KDE and Windows and needs their gates): an associated `type HideSpot: Copy + Default` on `ContainerWindow` (`()` on KDE and Windows), taken by the four hiding operations, with `WorkspacePlatform::hide_spot(&WorkspaceGlobals) -> W::HideSpot` (macOS: the monitor's corner and display, put into `WorkspaceGlobals` as a platform field) and the result type `eyre::Result<()>` for the four operations (`Ok(())` on KDE and Windows). About 10 shared functions change signature; the KDE and Windows callers pass `Default::default()`. Also needed before the Workspace switch, all small: `WorkspaceWindow::path` is `Option` and the macOS `Window::path` is a `PathBuf`; `class` has no macOS counterpart (role and subrole); `should_act_window` takes a class slice in the macOS `should_act`; the scrolling glue (`scrolling_insertion_idx`, `scrolling_idx_for_direction`, `note_scrolling_departure`) are methods of the macOS `Workspace` today and become the `WorkspacePlatform` hooks as in KDE. ## 15. Status after the HideSpot change (kfm) The decision of section 14 was made (D-coord-7) and the three macOS layers moved: - `ContainerWindow::HideSpot` (`()` on KDE and Windows, `MacosHideSpot {position, screen}` on macOS), the four window operations and `Container::{hide, restore, load_focused_window, add_window}` return `eyre::Result`, and the workspace asks `WorkspacePlatform::hide_spot` and `hide_spot_on_display` (both take the workspace; macOS keeps the corner in its `MacosWorkspaceState`). `Workspace::hide` and `new_container_for_window` return `Result`. - macOS `Container`, `Workspace` and `Monitor` are the shared types. What stays macOS's, and why, is in the `ContainerExt`, `WorkspaceExt` (update, apply_wallpaper, restore, remove_window, load_static_config, prune_duplicate_windows, visible_window_details, reap_invalid_windows_for_application, restore_windows, calculate_layouts, set_scrolling_columns), `WorkspaceScrolling` and `MonitorExt` (new, placeholder, the globals updates, load, hide and restore of the focused workspace, the two moves, add_container_with_direction) traits. - `ContainerExt` is the old macOS container code kept for the daemon's own call sites (the corner as an argument, `cfg!(test)` guards); it can go once those sites pass a `MacosHideSpot`. Nothing here has run on macOS. The Mini's `cargo test` is the oracle for the 53 workspace functions that were replaced by the shared text. ## 16. `update_shared` landed (wip-u-core2), and what macOS keeps `Workspace::update_shared` is in `komorebi-core` with the hooks of section 12 (`begin_update` returning `Flow`, `containers_pruned`, `place_monocle`, `maximize_in_layout`, `adopt_widths_before_layout`, `adopt_actual_widths`, `scrolling_layouts` (which folds the `monitor_rects` and `other_monitors` of the proposal into one hook, so each daemon keeps its own scrolling glue), `adjust_layout`, `place_container`, `end_update` returning `Pass`). It is named `update_shared` and not `update` so that a daemon's own `update` keeps working: the KDE and Windows `WorkspaceExt::update` are now `self.update_shared()`, and macOS's is untouched. Every hook has a default so that `MacosPlatform` compiles unchanged; the defaults of the ones a daemon must provide return an error. Where the implementation departs from section 12: `adjust_layout` and `place_container` take the container and the tile, not its index; the guard of `begin_update` is a boxed `dyn Any` (no associated type, so macOS needs nothing); the KDE closure around the placement is gone (it had nothing between it and its `?`); the Windows title-bar tables were first read once per container instead of once per pass, which was a regression, not an improvement: section 19 (the `begin_pass` hook) takes them once per pass again. **What macOS keeps (kfm's `update`, not touched):** its `update` is another shape, not the same text with other hooks. It wraps the placement in SkyLight's `SLSDisableUpdate`/`SLSReenableUpdate` (a `begin_update` guard could carry that); it has no maximized-window branch (the shared skeleton would call `maximize_in_layout` for a workspace whose `maximized_window` is set, which on macOS would change which branch runs); a scrolling workspace is placed by a separate `arrange_scrolling` that sets the rectangles itself and returns, not by the shared per-container loop; it computes the layouts with its own `calculate_layouts` and clamps with a free `clamp_to_work_area` in every case; it has no stackbar and no second pass. Adopting `update_shared` on macOS means three decisions for its owner (the maximized branch, folding `arrange_scrolling` into `scrolling_layouts` plus `place_container`, and whether the clamp stays unconditional); none is needed to finish the Windows and KDE steps. ## 17. Classification of `window_manager.rs`, `process_command.rs` and `process_event.rs` Method as in sections 7 and 14: `window_manager.rs` is cut into functions and compared function by function across the three daemons (comments and blank lines dropped, indentation stripped; "changed" counts the `<`/`>` lines of `diff`); `process_command.rs` and `process_event.rs` are one big `match` each, so they are compared arm by arm (the arm of `SocketMessage::X` or `WindowManagerEvent::Y`). Measured at the tip of `wip-u-core2` (KDE = `komorebi-linux`, Windows = `komorebi-windows`, macOS = `komorebi-macos`). Classes: **identical**, **drift** (a few changed lines: an id type, a name, an ordering), **same behaviour** (different text that does the same, found by reading the diff), **real** (a platform difference, a hook is needed), **one-sided** (one daemon only). ### `process_command.rs` (the router): 198 / 163 / 156 arms (Windows / KDE / macOS) | | arms | |---|---| | in all three, identical text | 110 | | in all three, text differs | 45 | | only in Windows (not-supported replies for macOS/KDE-only messages, `Notification`, `initial_state`) | 35 | | in Windows and KDE only (`FocusFollowsMouse`, transparency, `ReloadHotkeys`, `ToggleMaximize`) | 7 | | macOS and Windows only (`PanGesture`) | 1 | | KDE only (`HotkeysState`) | 1 | Of the 45 that differ: 20 are macOS still carrying the body inline where Windows and KDE call the named method of section 5 (`CloseWorkspace`, `CycleFocusWorkspace`, `EagerFocus`, `IgnoreRule`, `LayoutRatios`, `Query`, `ReplaceConfiguration`, `ResizeWindowAxis`, `SessionFloatRule`, `ToggleWorkspaceLayer`, the two named-workspace rules, `ToggleWorkspaceWindowContainerBehaviour`, `BorderColour`, `MoveContainerToLastWorkspace`, `CycleFocusEmptyWorkspace`, `DebugWindow`, `ManageRule`, `AddSubscriberPipe`): drift of shape, same behaviour once macOS calls the methods (kfm's conversion, step 9's precondition). The other 25 are KDE against Windows, in these clusters: - **same behaviour, written differently** (a method on one side, inline on the other, or `unwrap_or_else` against `match`): `FocusLastWorkspace` (Windows `focus_last_workspace()`, KDE inline), `FocusWorkspaceNumbers`, `Close`, `Minimize`, `State`, `SessionFloatRules`, `StaticConfigSchema`, `ApplicationSpecificConfigurationSchema`, `VisibleWindows` (the key of the map: Windows' device id, KDE's monitor id), `TogglePause` and `Retile` (modulo the border lines below). - **real**: `Border`, `BorderOffset`, `BorderWidth`, `ReloadStaticConfiguration`, `Retile`, `RetileWithResizeDimensions` (Windows' border implementations and `force_update_borders`); `StackAll`, `UnstackAll`, `StackWindow`, `UnstackWindow`, `CycleStack`, `CycleStackIndex`, `FocusStackWindow` (KDE switches animations off around them, Windows does not); `ResizeWindowEdge` (the Windows `live_resize::InPlace` guard); `FocusWorkspaceNumber` (Windows first focuses the monitor under the pointer when its workspace is empty). Order for the router: (1) macOS converges its arms onto the named methods (nothing shared is needed); (2) the 110 identical arms plus the 20 that become identical are one exhaustive `match` in the crate with each arm one call into `WindowManager`; (3) the remaining arms become `RouterPlatform` hooks with a default of "not supported" (`before_command`, `after_command`, the border arms, the animation guard around stacking, the live-resize guard, the pointer-monitor preamble). The 36 Windows-only and KDE-only arms are the "not supported" list of section 5 and need no hook. ### `process_event.rs`: 11 / 15 / 10 arms The event vocabularies are platform's own: only `Destroy`, `Show` and `Minimize` exist on all three, and their bodies differ (Windows 10/145/22 lines, KDE 44/112/8, macOS 44/168/4 for `Destroy`/`Show`/`Minimize`; changed lines against each other 48-153). KDE and macOS share six more (`MoveStart`, `MoveEnd`, `ResizeStart`, `ResizeEnd`, `ResizeStep`, `Restore`) with drift of 4-58 lines; Windows has eight of its own (`FocusChange`, `Hide`, `Maximize`, `Unmaximize`, `MouseCapture`, `MoveResizeStart`, `MoveResizeEnd`, `window`), KDE five (`FullscreenChange`, `MaximizeChange`, `OutputChange`, `TitleUpdate`, `WorkspaceChange`, `WorkspaceMove`), macOS one (`Unmanage`). So `process_event` is not shareable by converge: it is the "thin events" step of the plan (each platform translates its events into a small shared vocabulary, `Added`, `Removed`, `Focused`, `Moved`, `Resized`, `Minimized`, `Restored`, `Unmanaged`, and the shared handler runs the arms that are the same). It comes last, after `WindowManager`. ### `window_manager.rs`: 134 non-test functions in the union | | functions | |---|---| | in all three, identical | 39 | | in all three, differ | 48 (19 drift of 1-4 lines; 20 of 5-20; 10 of more than 20 against the Windows one) | | Windows only | 13: `apply_state` (131), `update_known_hwnds`, `raise_window_at_cursor_pos`, `workspace_transition_plan`, the custom-layout setters, `configure_watcher`/`watch_configuration`/`reload_configuration`, `focus_container_at_edge`, `focus_workspace_towards`, `remove_all_accents` | | KDE only | 4: `adopt_maximize_change`, `adopt_workspace_change`, `sync_workspace_desktops`, `reap_invalid_windows` | | macOS only | 16: the live resize family (`live_resize_*`, `apply_mouse_resize`, `resize_by_drag`, `edges_moved`), `entering_borders`, `workspace_transition`, `drag_origin`, `set_messaging_timeouts` | | Windows and KDE only | 4: `maximize_window`, `unmaximize_window`, `toggle_maximize`, `monitor_idx_from_window` | | KDE and macOS only | 2: `extract_minimized_window`, `update_known_window_ids` | The big differences (more than 20 changed lines Windows against KDE) are `focus_floating_window_in_direction` (105), `focus_container_in_direction` (69), `resize_window` (58, macOS 52), `update_focused_workspace` (57; KDE and macOS are identical), `enforce_workspace_rules` (43), `restore_all_windows` (40), `transfer_window` (36), `move_container_to_monitor` (30), `new` (25) and `monitor_idx_from_window` (23). Twenty functions are identical in KDE and macOS and differ only in Windows (`retile_all`, `change_workspace_layout_default`, `move_container_in_direction`, `promote_container_swap`, `toggle_monocle`, `float_window`, `focus_monitor`, `unstack_all`, ...): the Windows copy still has its older form, the KDE and macOS one is the one to share. 61 of the 91 functions that exist in Windows and KDE differ by at most 4 lines (42 not at all), so most of the layer is convergence of Windows onto the KDE/macOS text, which kfw can do function by function the way it did the container and workspace. Order for `WindowManager` (the next layers of step 9): (1) Windows converges the 20 KDE=macOS-identical functions and the 19 drift functions (mechanical, tests/e2e per cluster); (2) the struct moves as `WindowManager

` with `P::State` for the platform fields of section 2 (Windows: `known_hwnds`, `virtual_desktop_id`, `has_pending_raise_op`; KDE: `opacity_applied`, `minimized_windows`, `live_resize`; macOS: the live resize and drag state) and the 39 + converged identical functions; (3) the real differences become hooks, in this order: `monitor_idx_from_window`, `transfer_window`/`move_container_to_monitor` (the move across a monitor boundary), `restore_all_windows`, `enforce_workspace_rules`, `focus_*_in_direction` (floating windows), `resize_window` (live resize, shared by KDE-macOS first), `update_focused_workspace`; (4) the platform-only functions stay in each daemon's `WindowManagerExt` (the Windows custom layouts and watcher, the KDE desktop sync, the macOS live resize); (5) the router; (6) `process_event` with the thin events. ## 18. Converging `window_manager.rs` and the router: what is cosmetic, what is real, and why `WindowManager

` waits Measured at the tip of `wip-u-core3` (core2 plus one Windows revision), the same function comparison as section 17. In KDE's 2621 non-test function lines (stripped) of `window_manager.rs`: | | lines | share | |---|---|---| | in functions identical with the Windows copy | 689 | 26% | | in functions that differ by at most 4 lines from the Windows copy | 961 | 37% | | in functions that are KDE-only, or differ by more | 1660 | 63% | The threshold for creating `WindowManager

` (more than 60% shared) is not met and cannot be met by converging cosmetics: of the 19 functions that differ by 1 to 4 lines, almost all differ in behaviour. What the converge revision took (Windows to KDE's text): `eyre::bail!` in full, the `Lockable` form of `toggle_lock`, two log lines (one with a typo), and the `SessionFloatRules` and `State` arms. The rest of the near-identical functions, one by one: | function(s) | the difference | kind | |---|---|---| | `toggle_monocle`, `stack_all`, `remove_window_from_container`, `move_container_to_workspace`, `promote_container_to_front`, `move_container_in_cycle_direction` (and 12 more of the 20-line group) | Windows calls `handle_unmanaged_window_behaviour()` at the top of 18 individual functions; KDE and macOS call it once in the router for every message | real (when it refuses: a command about an unmanaged focus is refused per function on Windows, for every message on KDE/macOS) | | `monocle_on`, `monocle_off` | Windows also hides / restores the floating windows | real | | `cycle_monocle` | Windows destroys the borders | real | | `set_workspace_name` | Windows records the name in the monitor's extra `workspace_names` | real (a Windows field) | | `cycle_layout`, `promote_focus_to_front`, `promote_container_swap`, `change_workspace_layout_default` | the Windows `Layout::Custom` arms (`promote_focus_to_front` falls back to the first column, the others fail without a primary column) | real: hook `primary_container_idx` exists, but the fallback of `promote_focus_to_front` differs | | `apply_wallpaper_for_monitor_workspace`, `retile_all` | the monitor id is an `isize` handle on Windows, a `u32` on KDE; `retile_all` also destroys the borders on KDE | type (`PlatformMonitorId` would remove the first) and real | | `add_window_handle_to_move_based_on_workspace_rule`, `stack_all` | `hwnd: isize` against `window_id: u32` | type: `PlatformWindowId` | | `clear_workspace_layout_rules`, `maximize_window`, `focus_monitor`, `toggle_lock` | log text, `bail!`, `Lockable` | cosmetic: done | | `float_window` and the `focus_*` family | KDE reads `mouse_follows_focus` into a local (borrow); same behaviour | cosmetic, left (the local is KDE's borrow fix) | Larger real differences, from section 17's list: `focus_workspace` (Windows plans a slide transition, KDE asks the compositor; `focus_workspace_towards`, `workspace_transition_plan`), `init`, `stop` (the Windows state file, animations, focus-follows-mouse), `manage_focused_window` and `unmanage_focused_window` (an event against a notification), `monitor_idx_from_current_pos` (Windows refreshes a monitor from the system), `move_container_in_direction` (a DPI check across monitors), `move_floating_window_in_direction` (border offsets), `focus_floating_window_in_direction` (105 lines), `resize_window` (58; macOS 52 against KDE 0), `restore_all_windows`, `enforce_workspace_rules`. **The router, KDE against Windows.** The 25 differing arms: two made identical (`SessionFloatRules`, `State`); the rest are real and stay: `Close` and `Minimize` (Windows ignores a failed backend call with `let _ =`, KDE propagates it), `StaticConfigSchema` and `ApplicationSpecificConfigurationSchema` (Windows prints `schema_for!`, KDE and macOS the platform view of `komorebi-config`; the Windows output would change), `VisibleWindows` (the JSON key: Windows' device id, KDE's monitor id), `Border*`, `Retile*`, `ReloadStaticConfiguration`, `TogglePause` (borders and `force_update_borders`), `StackAll`/`UnstackAll`/`StackWindow`/`UnstackWindow`/`CycleStack*`/`FocusStackWindow` (KDE switches animations off around them), `ResizeWindowEdge` (the live-resize guard), and `FocusWorkspaceNumber`, `FocusWorkspaceNumbers`, `FocusLastWorkspace` (Windows first focuses the monitor under the pointer when its workspace is empty; the identical arms `CycleFocusWorkspace`, `CloseWorkspace` and `MoveContainerToLastWorkspace` do it on KDE too, so for these three it is probably an omission on KDE: decision for the owner). **Blocker list for `WindowManager

`:** 1. the `handle_unmanaged_window_behaviour` placement (router on KDE/macOS, per function on Windows): one `before_command` hook cannot give both; either Windows moves to the router (a behaviour change in which commands are refused) or the shared functions call a hook that is a no-op on KDE and macOS; 2. borders: the Windows border calls inside shared functions (`cycle_monocle`, `retile_all`, `Border*`) need a `BorderPlatform` hook with a no-op default; 3. the workspace transition (`focus_workspace`) needs the plan/execute split as a hook; 4. the platform fields of the struct (section 2) and `init`/`stop`/`new`; 5. `PlatformMonitorId` next to `PlatformWindowId` (the `isize`/`u32` handles in 12 functions); 6. the Windows custom layouts (the `primary_container_idx` hook covers most arms); 7. macOS's 16 own functions (the live resize family, `entering_borders`, `workspace_transition`) would stay in a `WindowManagerExt`, which means the text KDE and macOS share (20 functions identical between them, 6 of the router's arms) is the base, not the Windows text. Recommended next step: shared text first between KDE and macOS (kfm and kfde), where 20 window manager functions and most router arms are already identical, then Windows converges onto it function by function against the blocker list above; creating `WindowManager

` before blockers 1 to 3 are decided would move less than half of the file. ## 19. Corrections after the review of `komorebi-core` (findings F2 and F3) - **F2, the floating-window hook.** `Workspace::remove_floating_window` calls the hook before it takes the window out of the ring (the KDE and macOS originals move the focus first, and the focus index moves back by one whatever the length), so the hook is `before_floating_removed`, not `after_floating_removed` as sections 8 to 11 call it. Behaviour is unchanged; a test pins the order. - **F3, the title-bar snapshot.** The original Windows `update` read `REMOVE_TITLEBARS`, `NO_TITLEBAR` and `REGEX_IDENTIFIERS` once per placement pass; the first Windows `place_container` read them per container, so a configuration reload in the middle of a pass could give two containers two tables (section 16 wrongly called this an improvement). `WorkspacePlatform::begin_pass` is called once per pass after the layout is computed and its answer reaches the hooks as `Placement::pass`; Windows takes the snapshot there and `place_container` reads it, as the original did. ## 20. KDE and macOS: the converge of `window_manager.rs` and the router, and why `WindowManager

` still waits Measured at the tip of `wip-u-wm` (cand5 plus the revisions below; no Windows file changed). **Done (refactors, no behaviour change):** both daemons ask `window::foreground_window_id()` and `window::monitor_id_at_cursor()` instead of calling the backend or `MacosApi` inline (the macOS `window.rs` gets the two one-line wrappers); twelve macOS `process_command` arms (eleven methods) that were the same code inline as KDE's named methods now call methods with those names and bodies (`cycle_focus_workspace`, `cycle_focus_empty_workspace`, `close_workspace`, `move_container_to_last_workspace` for both the move and the send arm, `eager_focus`, `set_border_colour`, `set_layout_ratios`, `answer_query`, `add_named_workspace_rule`, `add_initial_named_workspace_rule`, `toggle_workspace_window_container_behaviour`). **Where it stands:** | | KDE against macOS | |---|---| | router arms present in both | 155 | | of them byte-identical | 137 | | of them different | 18: `Close`, `Minimize` (macOS window API), `DebugWindow`, `ManageRule`, `IgnoreRule`, `SessionFloatRule` (macOS identifies an application by role where KDE uses the class), `ReplaceConfiguration` (the run loop; no warning on macOS), `AddSubscriberPipe`, `ToggleWorkspaceLayer` (88 lines inline on macOS), `ResizeWindowAxis` (59 inline), the stack and unstack arms and `CycleStack*`, `FocusStackWindow` (KDE switches animations off around them), `ResizeWindowEdge` (1 line) | | `window_manager.rs` function lines identical with macOS | 1156 of KDE's 2595 (45%); 105 functions on macOS, 97 on KDE | The 45% is below the 60% bar, and what separates the rest is not text: - **the hiding spot and the container API.** macOS calls `container.hide_on(spot, omit, screen)`, `load_focused_window_at(spot)?` and `restore_focused_window()?`, its own `ContainerExt`, where KDE and the shared crate have `Container::hide(omit, spot)`, `load_focused_window(spot)` and `restore()`; nine window manager functions (`add_window_to_container`, `cycle_container_window_*`, `focus_container_window`, `stack_all`, `monocle_on/off`, `cycle_monocle`, `transfer_window`) differ in exactly these calls; - **the error policy:** KDE writes `let _ = container.load_focused_window(())` (its window operations cannot fail), macOS writes `...?` (AX calls can); the shared form is `?`, which is equivalent on KDE because its operations always answer `Ok`; - **platform entry points:** `init`, `stop`, `new`, `focus_workspace`, `manage_focused_window`, `unmanage_focused_window`, `restore_all_windows`, `enforce_workspace_rules`, `resize_window` (52 lines), `move_container_to_monitor`, `focus_floating_window_in_direction` (29); - **one-sided functions:** macOS 16 (the live-resize family, `entering_borders`, `workspace_transition`, `drag_origin`), KDE 4 (`adopt_maximize_change`, `adopt_workspace_change`, `sync_workspace_desktops`, `reap_invalid_windows`). **Blockers for a `WindowManager

` over KDE and macOS (Windows keeps its own):** 1. macOS's `ContainerExt` (`hide_on`, `load_focused_window_at`, `restore_focused_window`) against the shared `Container` with a `HideSpot`: kfm converges the container first (its semantics: AX errors, `cfg!(test)` guards) so that the nine functions become identical; 2. the `?` against `let _` policy, then a mechanical KDE edit; 3. the eleven platform entry points above as `WindowManagerPlatform` hooks (`init`, `stop`, `new`, `focus_workspace`, `restore_all_windows`, ...), which is as many hooks as functions moved, so the shared text would be about half of the file with a dozen hooks: not worth it yet; 4. the 18 differing router arms, of which the stack family (animation guard) and the application identity (role against class) are hooks and the rest are macOS's own bodies. Recommended next step: kfm unifies the container API (blocker 1) and the `?` policy (2) on both daemons; after that the nine functions are identical and the shared share passes 60% without new hooks, and `WindowManager

` can be created over KDE and macOS with the entry points of blocker 3 left in each daemon's extension trait. ## 21. The shared commands (`komorebi_core::command`, wip-u-cmd) **Mechanism.** The daemons' `WindowManager` is per platform, so the commands cannot be methods of a shared type yet; they are free functions generic over a *host*, a small trait the daemon implements by forwarding to its own methods: `CommandHost` (monitors, focused monitor, the monitor under the pointer, `focus_monitor`, `focus_workspace`, `move_container_to_workspace`), `MonitorCommandHost` (mouse follows focus, `update_focused_workspace`, `direction_from_monitor_idx`, `move_container_to_monitor`, the focused workspace index) and `ContainerCommandHost` (`monitors_mut`). A recording stub host in `crates/komorebi-core/tests/commands.rs` tests each function natively, naming the e2e check of `commands.py` it mirrors; this is the model-level command harness that only KDE had. **Moved (sixteen router arms, the same text on all three daemons):** the workspace navigation (`CycleFocusWorkspace`, `CycleFocusEmptyWorkspace`, `CloseWorkspace`, `FocusLastWorkspace`, `MoveContainerToLastWorkspace`, `SendContainerToLastWorkspace`), the moves across workspaces and monitors (`CycleMoveContainerToWorkspace`, `CycleSendContainerToWorkspace`, `CycleFocusMonitor`, `CycleMoveContainerToMonitor`, `CycleSendContainerToMonitor`, `FocusMonitorWorkspaceNumber`, `MoveContainerToMonitorWorkspaceNumber`, `SendContainerToMonitorWorkspaceNumber`) and the lock and unlock of a container by index. The one platform difference found, that Windows lets the pointer pick the monitor first for the empty, close, last-workspace and workspace-number commands (an omission on KDE and macOS, section 18), is gone: all three do it now (wip-u-ptr), so the const that held the difference was removed. **What stays, and why** (identical across the three unless noted; each needs a `WindowManager` method or field, so moving them means moving `WindowManager`, the blockers of section 18): - the moves and focus by direction (`MoveWindow`, `FocusWindow`, `CycleFocusWindow`, `CycleMoveWindow`, `PreselectDirection`, `Promote*`, `StackWindow`, `UnstackWindow`, `StackAll`, `CycleStack*`): they call `move_container_in_direction`, `focus_container_in_direction` and the floating variants, which are the big window manager functions with the platform window operations; - the named-workspace commands (`MoveContainerToNamedWorkspace`, `SendContainerToNamedWorkspace`, `FocusNamedWorkspace`, the layout and tiling by name): need `monitor_workspace_index_by_name`, a `WindowManager` method over the monitors (a candidate for the next host method once the names are in `Monitor`); - the behaviour toggles (`ToggleCrossMonitorMoveBehaviour`, `ToggleWindowContainerBehaviour`): fields of `WindowManager`; - the layout and rule commands (`WorkspaceLayout*`, `WorkspaceRule`, `ContainerPadding`, `WorkAreaOffset`, ...): thin calls of `WindowManager` methods that edit a workspace and re-layout, the same coupling; - queries, configuration, borders, animations, transparency, schema: statics and the platform. ## 23. Design note: thin events, a common vocabulary for `process_event` (not implemented) Status: design only (D-coord-13 (c)). Nothing here is built; the event enums and handlers are unchanged. The variant lists below were read from the three `window_manager_event.rs` files; how each handler arm behaves was read at section 17 and not re-measured here. **Today.** Each daemon has its own `WindowManagerEvent`, built from the platform notification: - Windows: `Destroy, FocusChange, Hide, Cloak, Minimize, Show, Uncloak, MoveResizeStart, MoveResizeEnd, MouseCapture, Maximize, Unmaximize, Manage, Unmanage, Raise, TitleUpdate`, most carrying `(WinEvent, Window)`. - KDE: `FocusChange, Show, Destroy, Minimize, Restore, TitleUpdate, FullscreenChange(bool), OutputChange, MaximizeChange(bool), Manage, Unmanage, MoveStart, MoveEnd, ResizeStart, ResizeEnd, ResizeStep, WorkspaceChange(desktop, idx), WorkspaceMove`, carrying `(SystemNotification, Window)`. - macOS: `FocusChange, Show, Destroy, Minimize, Restore, Manage, Unmanage, MoveStart, MoveEnd, ResizeStart, ResizeStep, ResizeEnd, SpaceChange, ScreenLock, ScreenUnlock`, carrying a process id and a window id instead of a `Window`. Only `Destroy`, `Show` and `Minimize` exist under the same name on all three; `FocusChange`, `Manage`, `Unmanage`, `TitleUpdate` exist on Windows and KDE (macOS has the first three). The payloads differ: the platform notification, a `Window` or raw ids. So a shared `process_event` cannot match on today's enums. **Proposal.** A small enum in `komorebi-core`, generic over the platform's window id and window type, that the platform adapter produces from its notification and that describes what happened, not how the OS said it: ```rust pub enum CoreEvent { Appeared(W), // Show, Manage, Uncloak, WindowAdded Gone(W), // Destroy, Unmanage, Hide of a managed window Minimized(W), // Minimize, Cloak on Windows Restored(W), // Restore, Unminimize, Uncloak FocusMoved(W), // FocusChange MoveOrResize(Phase, W), // Start, Step, End for Windows MoveResize*, KDE and macOS Move/Resize FullscreenOrMaximize(W, bool), Retitled(W), OutputChanged(W), WorkspaceChanged(..), // KDE virtual desktop, macOS space: the platform tells which workspace } ``` The Windows events that have no other-platform meaning (`MouseCapture`, `Raise`, `Cloak`/`Uncloak` distinctions) stay platform events: the adapter turns them into `CoreEvent` where they mean the same thing and handles the rest itself before or after. A handler for a `CoreEvent` is a `WindowManager` method with a platform hook for each decision the arms make today: `should_manage` (rules, already in `komorebi-rules` or in the daemon), whether the window is on this monitor, the border and transparency refresh after the layout, and the unmanaged-window check at the top (section 18 blocker; the router form is the target). **What a shared handler looks like.** For `Gone(w)`: find the container holding `w`, `Workspace::remove_window` (shared), prune empty containers, `update`, then the border hook. For `Appeared(w)`: ask the `should_manage` hook, find the target monitor and workspace through the (shared) rules, `Workspace::new_container_for_window`, `update`, focus hook. For `Minimized(w)`: shared `Workspace` already hides and updates. These three account for the arms the three process_event files share most (sections 17 and 18). **Prerequisites** (in order): the unmanaged-window-check converged to the router form on Windows (kfw); `WindowManager

` or at least a shared `WindowManagerState

` in core so the shared handler has a `self` (section 18 says the 60% threshold is not met yet); a `PlatformMonitorId`; the border calls behind hooks. Without the first two the shared handlers have nothing to run on. **Risks.** The ordering of side effects inside each arm is part of today's behaviour, for instance Windows doing the border update before the focus event is forwarded, and KDE debouncing `ResizeStep`; a shared handler must keep the order per platform through hooks, or the change is a BEHAVIOUR change needing the Windows e2e, the KDE VM suites and the macOS hand check. `FocusChange` on macOS carries an optional window id and there is no `Window` yet at that point, so the `CoreEvent` payload cannot always be a window. **Decision wanted from the owner.** Whether to pursue this after `WindowManager

` lands; the gain is the event-handling arms (about a third of `process_event.rs` by line count at section 17) running natively on a stub for all three platforms. ## 22. After the macOS deep dedup (kfm, D-coord-13): the container API, the arms, and `update` **Done.** (1) macOS `ContainerExt` is gone: every container call in the macOS daemon is the shared `Container::{add_window, load_focused_window, hide, restore}` with `workspace.hide_spot()`, a method of `Workspace` that is `P::hide_spot(self)` (`()` on KDE, `MacosHideSpot` on macOS). (2) the nine window manager functions that differed only in these calls (`add_window_to_container`, `cycle_container_window_in_direction`, `cycle_container_window_index_in_direction`, `focus_container_window`, `stack_all`, `monocle_on`, `monocle_off`, `cycle_monocle`, `transfer_window`) are now byte-identical in `komorebi-for-kde` and `komorebi-for-mac`; the `?` form replaced KDE's `let _ =` (equivalent, its operations always answer `Ok`). Measured with the function splitter: 76 of the 92 function names that the two `window_manager.rs` share have the same text, and 1521 of KDE's 2713 stripped lines are identical with macOS (56%, from 45% in section 20; the 60% bar needs the platform entry points of blocker 3). (3) `ToggleWorkspaceLayer`, `ResizeWindowAxis`, `SessionFloatRule`, `IgnoreRule` and `ReplaceConfiguration` are methods of the KDE names on macOS and the router arms are the KDE text; `Close`, `Minimize`, `DebugWindow` and `ManageRule` stay macOS's own arms (the foreground element and the role instead of the class; there is no KDE method to match). **`update` is not adopted, and why (exact differences from `update_shared`).** Each is a reason, and the first two alone would make it unprovable: 1. **A scrolling workspace returns early.** macOS's `update` does `return self.arrange_scrolling(..)` inside the tile branch, so for a scrolling workspace it never reaches the closing `self.resize_dimensions.resize(container_count, None)` that `update_shared` always runs, and it places the windows itself (drag-step layout, `adopt_actual_sizes`, `hide_scrolling` at the display edge, the animator), not through one `place_container` per container. Folding it into `scrolling_layouts` plus `place_container` would change both the placement order and whether the resize dimensions are truncated for a scrolling workspace. 2. **The SkyLight batch is wider.** macOS disables screen updates around the whole tile branch (monocle, scrolling and the container loop) and re-enables on every exit; a `begin_update` guard would start before `prune_duplicate_windows` and the container retain and end after the resize-dimensions fix-up, and would be taken when `tile` is false as well. Probably harmless, not provable. 3. **Own `calculate_layouts`**: the same arrangement call as `update_shared`'s inline one (checked line by line), so this one alone would fold. 4. **Unconditional clamp**: `clamp_to_work_area` runs for every non-scrolling layout; the shared `adjust_layout` hook can do that (as KDE's does). 5. **No maximized branch**: the macOS workspace never has a `maximized_window` (no writer), so the shared branch is unreachable; `maximize_in_layout` is not needed. 6. **Placement errors are logged and the next window is placed** (`tracing::warn!`): a `place_container` hook can swallow them as KDE's does. 7. **No second pass and no stackbar**: `end_update` answers `Pass::Done`. Items 3 to 7 would fold with hooks; 1 and 2 are the blockers, and 1 is the owner's decision: either the macOS scrolling placement becomes the `scrolling_layouts` and `place_container` hooks (and the truncation of the resize dimensions is accepted for a scrolling workspace, which is a behaviour change), or `update` stays macOS's. ## 24. What Windows keeps platform-specific for a shared `WindowManager

` (kfw, wip-u-win-deep) Written against `wip-cand7` plus the revisions of `wip-u-win-deep`: `PlatformMonitorId

` is in `komorebi-core` (blocker 5 of section 18), the Windows `monitor_idx_from_current_pos` has KDE's text, and `promote-focus` on a custom layout is the `WorkspacePlatform::promote_focus_container_idx` hook (blocker 6, the fallback part). This is the hook list kfde can implement: every item says what Windows does, what KDE and macOS do (from sections 17, 18 and 20), and the proposed shape. Items are ordered by how many shared functions they touch. ### 22.1 The unmanaged-window gate (blocker 1): not converged, and why `handle_unmanaged_window_behaviour()` (refuse a command when `OperationBehaviour::NoOp` is set and the focused window is not in a container of the focused workspace) is called at the top of 18 functions on Windows: `move_container_to_monitor`, `move_container_to_workspace`, `focus_container_in_direction`, `move_container_in_direction`, `focus_container_in_cycle_direction`, `move_container_in_cycle_direction`, `cycle_container_window_in_direction`, `cycle_container_window_index_in_direction`, `focus_container_window`, `stack_all`, `unstack_all`, `add_window_to_container`, `promote_container_to_front`, `promote_container_swap`, `promote_focus_to_front`, `remove_window_from_container`, `toggle_monocle`, `toggle_maximize`. KDE and macOS call it once in the router, before the `match`, for every message. Moving Windows to the router is **not** behaviour-neutral and was not done: the reading of `process_command` shows four ways in which the two forms differ. 1. The router form refuses every message, including queries, rule and configuration commands, the workspace-focus commands and `stop` (those never call one of the 18); the per-function form refuses only what reaches one. A router that gated the same set would need a message list (`requires_managed_focus(&SocketMessage)`) that nothing shares. 2. An arm that does other work first is not refused as a whole on Windows: `FocusStackWindow` focuses the monitor under the pointer and only then calls `focus_container_window`; `MoveContainerToNamedWorkspace` and the `Cycle*Container*` arms look up the target first; a router gate would refuse before any of it. 3. The layer split: `FocusWindow`, `MoveWindow` and `CycleFocusWindow` call a gated function in the Tiling layer and an ungated floating function in the Floating layer, so one message is refused in one layer only; a router gate refuses both. 4. Nested and direct callers that do not pass the router: `focus_container_window` is also called from the arm of `process_command` that focuses a window by its location (`WorkspaceWindowLocation::Monocle` and `Container`), `move_container_to_workspace` from `move_container_to_monitor` and from `move_container_in_direction`, `toggle_monocle` and `toggle_maximize` from `move_container_in_direction` and the cycle functions, and the unit tests of `window_manager.rs` call all of them directly. The nested calls are covered by the outer gate; the direct ones are not. The hook that makes both shapes expressible is a defaulted method on the shared router, `fn gate_message(&self, message: &SocketMessage) -> eyre::Result<()>`: the default (KDE, macOS) calls `handle_unmanaged_window_behaviour` for every message; Windows returns `Ok(())` and keeps the call inside the 18 functions (they stay Windows text until the owner decides that Windows should refuse more). ### 22.2 Hooks of the window manager (`WindowManagerPlatform`) | Windows keeps | What it is | KDE / macOS | Shape | |---|---|---|---| | `new`, `init` | `new` takes the command listener, the event channel and the socket; `init` loads monitor and workspace information through `WindowsApi`, uncloaks left-behind windows, starts the border and stackbar machinery | KDE: backend monitors and desktops; macOS: run loop and AX | `fn init(&mut WindowManager)`, `fn new(..)` as associated functions; the struct's platform fields in `P::State` (section 2: `known_hwnds`, `virtual_desktop_id`, `uncloack_to_ignore`, `has_pending_raise_op`, `pending_move_op` with handle and rect, `already_moved_window_handles`) | | `stop` | writes `komorebi.state.json`, switches animations off, waits for them, restores every window, turns focus-follows-mouse off, shuts the subscriber sockets, removes the socket file, waits for a bar restart in progress, `process::exit(0)` | KDE: `backend().shutdown()`; macOS: its own | `fn stop(&mut WindowManager, ignore_restore: bool)`; the shared part is the restore, the rest is the hook | | `restore_all_windows` | restores each window by `WindowsApi` (cloak/hide/minimize, the maximized and monocle windows), skipping windows the user hid | KDE and macOS have their own | `fn restore_all_windows(..)`; Windows' body stays | | `resize_window` | resizes by edge with DPI scaling and the min-size rules (58 lines); `ResizeWindowEdge` additionally goes through the live-resize guard | macOS 52 lines of its own, KDE none (the compositor) | `fn resize_window(..)`; the arm `ResizeWindowEdge` is a hook too | | `focus_workspace` | plans a slide transition (`workspace_transition_plan`, `focus_workspace_towards`) and runs the animation, or shows and hides windows | KDE asks the compositor for the desktop | plan/execute split: `fn plan_workspace_transition(..) -> Plan` (shared) and `fn execute(plan)` (hook) | | `enforce_workspace_rules`, `manage_focused_window`, `unmanage_focused_window`, `monitor_idx_from_window` | rules apply to windows found by `WindowsApi` and move them with `SetWindowPos`; manage/unmanage are fed by an event on Windows and a notification on KDE | differ in input | hooks `fn windows_to_enforce(..)`, `fn manage_focused(..)` | | `move_container_in_direction` | a DPI check across monitors (`monitors_have_same_dpi`) before a cross-monitor move | none | `fn before_cross_monitor_move(a, b) -> eyre::Result<()>`, no-op default | | `move_floating_window_in_direction`, `focus_floating_window_in_direction` | border offsets and the floating-window search (105 lines) | KDE and macOS: 29-line variants | stay platform; extension trait | ### 22.3 Borders, stackbars, animations: the `BorderPlatform` hook `window_manager.rs` calls the border manager in few places: `cycle_monocle` destroys the borders (`border_manager::destroy_all_borders`), `stop` reads the border implementation, the ghost kinds are set in one free function; the router arms `Border*`, `Retile*`, `ReloadStaticConfiguration` and `TogglePause` set `force_update_borders` and notify the border thread. `monocle_on` and `monocle_off` also hide and restore the floating windows (through the backend). The stackbar manager is not called from `window_manager.rs`. KDE draws borders in the compositor and calls none of these, macOS has its own border thread. Shape: a defaulted `BorderPlatform` with `fn borders_changed(&mut self)`, `fn destroy_borders(&mut self)`, `fn hide_floating_for_monocle(&mut self, hide: bool)`; defaults do nothing (KDE) and macOS implements its own. The `force_update_borders` flag of the router becomes the return value of those hooks, as `replace_configuration` already returns it. ### 22.4 Wallpaper, window-handling behaviour, workspace names, custom layouts - **Wallpaper.** `apply_wallpaper_for_monitor_workspace` finds the wallpaper of a monitor/workspace pair and calls `Workspace::apply_wallpaper` (which is the `WorkspacePlatform::apply_wallpaper` hook: `WindowsApi::set_wallpaper` through COM on Windows, the backend on KDE, nothing on macOS); the shared function takes a `PlatformMonitorId` now and can move as it is. - **Window handling behaviour.** `WINDOW_HANDLING_BEHAVIOUR` (Sync/Async, the `ASYNC_WINDOW_POS` flag of `SetWindowPos`) is read inside `WindowsApi::position_window`, never in `window_manager.rs`: it is a setting of the Windows `set_rect`, not a hook of the window manager. Keep it in `Settings::platform`. - **Workspace names.** `set_workspace_name` records the name in the monitor's `Extra` (`workspace_names`), and `known_workspace_name` is already the `MonitorPlatform` hook; a shared `set_workspace_name` calls a `fn record_workspace_name(monitor: &mut Monitor, idx: usize, name: &str)` hook (default: nothing). - **Custom layouts.** `Layout::Custom` stays in `komorebi-layouts`; the Windows arms of `cycle_layout`, `promote_container_swap` and `change_workspace_layout_default` fail for a custom layout without a primary column, which `primary_container_idx` already expresses; the one real difference (`promote-focus` falls back to the first column) is `promote_focus_container_idx`, done in this branch. ### 22.5 Cosmetic differences: nothing left to do Section 18 lists which near-identical functions were cosmetic and converged (`bail!`, `Lockable`, log text, the `SessionFloatRules` and `State` arms); the rest of its table is real. Re-reading the Windows and KDE copies of the functions that differ by at most four lines against that table found no cosmetic difference left (`float_window` and the `focus_*` family read `mouse_follows_focus` into a local on KDE because of a borrow, and Windows has no such need: they stay as they are). ### 22.6 Order for kfde 1. `fn gate_message` on the shared router (22.1), Windows `Ok(())`. 2. `BorderPlatform` (22.3) with no-op defaults: unblocks `cycle_monocle`, `retile_all`, `monocle_on/off`. 3. `record_workspace_name` (22.4) and the `PlatformMonitorId` use in the shared wallpaper function. 4. The plan/execute split of `focus_workspace`, then `init`/`stop`/`new`/`restore_all_windows` as hooks. Gates for each: Windows `commands`, `workspaces`, `focus`, `stacking`, `monocle` (maximizing), `borders`, `layouts` scenarios; KDE fast tier and VM for the router change. ## 25. Why `WindowManager

` still does not move, measured on `wip-cand8` (kfde, wip-u-wm2) The function comparison of sections 17, 18 and 20 repeated on the three `window_manager.rs` files at `wip-cand8` (stripped function bodies, test module excluded; Windows 4464 lines, KDE 3766, macOS 4543): | | functions | lines | share of Windows | share of KDE | share of macOS | |---|---|---|---|---|---| | identical on all three | 43 | 696 | 15.6% | 18.5% | 15.3% | | differ by at most 4 lines pairwise on all three | 81 | 1719 (KDE count) | about 39% | 46% | about 38% | | identical on KDE and macOS (Windows may differ) | 102 | 2427 (KDE count) | - | 64% | 53% | The threshold of "more than 60% shared by all three" is not met, and the table shows it cannot be met by moving what is already the same text: even adding the near-identical functions (which would each need a converge revision first) gives 46% at best. Only KDE and macOS together pass 60% (64% of KDE's lines are identical to macOS's), which is not the goal set for `WindowManager

` (all three). **The struct.** The common fields (`monitors`, `monitor_usr_idx_map`, `command_listener`, `is_paused`, `resize_delta`, `hotwatch`, `work_area_offset`, the six behaviour settings, `mouse_follows_focus`, `focus_follows_mouse` on Windows and KDE) are about 15 of 21 to 24 fields per daemon, so a `WindowManager

` with `P::State` for the rest is possible in principle. What makes the move too invasive for one batch is what the fields' types and the identical functions depend on: - `incoming_events: Receiver` names the daemon's event enum, which has a different vocabulary on each platform (section 23); the struct would need `P::Event`. - the platform fields differ in type, not only in name: `pending_move_op` is `(usize, usize, isize, Rect)` on Windows and `(usize, usize, u32)` on KDE and macOS, `known_hwnds: HashMap` against `known_window_ids: HashMap`, `already_moved_window_handles` of `isize` against `u32`; all of that belongs in `P::State`, and each of the 43 functions that touches one of them stops being identical text. - the 43 identical functions call `Window`, `Rect` and `WindowManagerEvent` methods of the daemon's own types and daemon-only methods of the window manager itself (the other ~60 functions of section 17 that stay); in a generic impl each needs a trait bound or a hook, and the daemon-only methods become an extension trait as for `Workspace`. That is hundreds of call sites per daemon (every `wm.field` access in `process_command.rs`, `process_event.rs`, the border and stackbar threads on Windows) for the 16% of Windows text that would actually become shared. - the gain is therefore small and the risk high: the native-test benefit comes from the logic that lives in `Workspace

`, `Monitor

` and `komorebi_core::command`, which already run on a stub. **What was done instead (D-coord-13 follow-up):** the one structural prerequisite that does not need the struct, `CommandHost::gate_message` (section 22.1), is in. A shared router needs the struct and waits for it. **Largest honest next subset, if the owner wants it:** a `WindowManager

` for KDE and macOS only (64% identical), with Windows following when kfw's hooks (section 24) exist; every daemon still needs the `P::Event` and `P::State` split above first. This is a decision for the owner, not a step to take unattended: it changes the shape of every `wm.` access in two daemons. ## 26. The command seam after D-coord-17 (wip-u-cmd2): 28 arms, what is left With the struct move dropped (section 25), the shared logic grows through `komorebi_core::command` (free functions over thin host traits; each daemon's `WindowManager` forwards). Twelve more arms moved on all three daemons, 28 in total: `MoveWindow`, `FocusWindow`, `CycleFocusWindow` (the layer dispatch), `PreselectDirection`, `CancelPreselect`, `PromoteWindow`, the three named-workspace commands (`FocusNamedWorkspace`, `MoveContainerToNamedWorkspace`, `SendContainerToNamedWorkspace`, over the shared `monitor_workspace_index_by_name`) and the three toggles (`ToggleWindowContainerBehaviour`, `ToggleCrossMonitorMoveBehaviour`, `ToggleMonocleFocusBehaviour`). Host traits added: `WindowCommandHost` (nine thin forwards) and `BehaviourCommandHost` (three settings, get and set). Still per daemon although the text is identical on all three (found by comparing the arms of the three routers at `wip-cand8`): the rule and padding commands (`WorkspaceRule`, `InitialWorkspaceRule`, `*LayoutRule`, `*Padding`, `NamedWorkspace*`; 6 to 16 lines each, they only call `Workspace` and `Monitor` methods and would move with a host that hands out `&mut Monitor`), the animation commands (14 lines each, settings), the lock, save and load commands, `Toggle*Override` and the work-area offsets (they call `retile_all`, which has border calls on Windows), and the query commands (`State`, `GlobalState`, `MonitorInformation`, schemas). These are the next clusters; `Promote`, `PromoteSwap` and `PromoteFocus` are one-call arms with no logic to move. One-call arms were not counted as moved. ## 27. The macOS side of the shared router and `WindowManagerPlatform` (kfm, wip-u-mac-wm2) Written while kfde's `gate_message` hook and `WindowManagerPlatform` trait (branch `wip-u-wm2`) did not exist yet, so nothing here implements them; it records what macOS needs and what was done meanwhile. **The gate (22.1).** macOS calls `handle_unmanaged_window_behaviour()` once, in `process_command`, after `State::from` and before the `match`, for every message: the same call at the same place as KDE (KDE has `is_layout_command` and maximized check before it, which macOS does not need, and macOS has a space-id return before it, which KDE does not have). The body is the same text. So the default `gate_message` (call it for every message) is what macOS does today, and macOS needs no override. **Entry points as hooks** (the table of 22.2 for macOS; none takes the window manager lock from a main queue closure, none dispatches to main itself; the ones that already block main under the lock keep the hazard of `seam.md` 3.3 and do not add one): | function | what it is on macOS | hook shape | seam note | |---|---|---|---| | `new(run_loop, incoming, custom_socket_path)` (50 lines) | takes the main run loop; builds monitors from nothing; a thin forward as `P::new(..)` needs the run loop as a platform argument | `fn new(&PlatformArgs) -> WindowManager` with the run loop in the platform's args | no lock | | `init` (5 lines) | `MacosApi::load_monitor_information` then `load_workspace_information` (AX and CoreGraphics, on the calling thread) | `fn init(&mut WindowManager)` | runs before the daemon's threads start (main.rs), no lock contention | | `stop(ignore_restore)` (26 lines) | restores windows, writes the state, closes subscriber sockets (takes `SUBSCRIPTION_SOCKETS`), exits | `fn stop(..)` | takes only the registry-like statics, not the WM lock again | | `restore_all_windows(ignore_restore)` (17 lines) | AX restore per window | the shared part is the loop; the restore is `Container::restore` with the spot | AX under the WM lock, as before | | `focus_workspace` (38) | calls `border_manager::hide_borders_on`, an `exec_sync` to main **under the WM lock** (the pre-existing hazard of 3.3) | hook `workspace_transition` | unchanged hazard; the hook must not make it worse (no second dispatch) | | `resize_window` (240), `move_container_to_monitor` (130), `focus_floating_window_in_direction` (245), `enforce_workspace_rules` (202), `manage_focused_window` and `unmanage_focused_window` (19, 15) | the live-resize and AX identity code | stay macOS's; not hooks | | `ReplaceConfiguration` is `replace_configuration`, taking the run loop from `self` (section 22 of this document says it is a method since the deep dedup); the hook, if any, is `P::preload`. **Done meanwhile.** `Minimize`, `Close` and `DebugWindow` are methods on both daemons (the arms are the same text, the bodies the platform's); the ignore and manage rules use the shared `push_simple_rule` (kind and id; macOS used to compare only the id, a small fix). **`update` stays macOS's** (D-coord-15): section 22 gives the two blockers (the scrolling early return and the width of the SkyLight batch), and the shared `WindowManager

` does not need `update`: it calls `update_focused_workspace`, which is the daemon's.