From 1b10611022499877bbdca86aede84e2e1fa4c7ef Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 17:24:36 -0700 Subject: [PATCH] feat(wm): ignore, manage and float rules komorebi managed every normal window that is alive, and there was no way to keep an application out of the layout. The rule engine decides whether a window is managed: an ignore rule keeps the windows it matches out, unless a manage rule matches them too, and a float rule puts a window in the floating windows of its workspace when it opens. A rule matches a window by its title, its executable, its class or the path of its executable. Plasma's shell and KRunner are ignored from the start. `komorebic ignore-rule` and `manage-rule` add a rule, `session-float-rule` floats the focused window and the windows like it until the daemon stops, `clear-session-float-rules` removes those rules, and `global-state` shows the rules. The `DebugWindow` message says why a window is managed or not. Applications often open a window before they give it its title, so the bridge now reports a title change, and a window komorebi does not have is asked about again when its title changes, for a rule on the title to decide. The vm-commands sweep adds and shows rules, and a section of its own checks that a window an ignore rule matches by its first title is tiled once it has another. vm-kwin-events checks that a window giving itself another title reaches the daemon as a title update. --- Cargo.lock | 13 + README.md | 5 +- docs/cli/clear-session-float-rules.md | 10 + docs/cli/global-state.md | 10 + docs/cli/ignore-rule.md | 21 + docs/cli/manage-rule.md | 21 + docs/cli/session-float-rule.md | 10 + docs/cli/session-float-rules.md | 10 + docs/dev/ARCHITECTURE.md | 48 +- docs/index.md | 2 + docs/usage/managing-windows.md | 9 +- docs/usage/rules.md | 71 +++ komorebi/Cargo.toml | 1 + komorebi/src/core/mod.rs | 12 + komorebi/src/desktop.rs | 7 +- komorebi/src/kwin/bridge.js | 11 +- komorebi/src/kwin/bridge.test.js | 16 + komorebi/src/kwin/kwin-api.d.ts | 1 + komorebi/src/kwin/state.rs | 34 ++ komorebi/src/lib.rs | 31 ++ komorebi/src/process_command.rs | 203 +++++++- komorebi/src/process_event.rs | 101 +++- komorebi/src/state.rs | 65 +++ komorebi/src/window.rs | 597 +++++++++++++++++++++++- komorebi/src/window_manager.rs | 2 +- komorebi/src/window_manager_event.rs | 8 +- komorebi/tests/rule_commands.rs | 238 ++++++++++ komorebi/tests/rules.rs | 74 +++ komorebic/src/main.rs | 64 +++ komorebic/tests/cli.rs | 44 ++ nix/tests/vm/suites/commands/test.py | 50 +- nix/tests/vm/suites/kwin-events/test.py | 23 +- 32 files changed, 1755 insertions(+), 57 deletions(-) create mode 100644 docs/cli/clear-session-float-rules.md create mode 100644 docs/cli/global-state.md create mode 100644 docs/cli/ignore-rule.md create mode 100644 docs/cli/manage-rule.md create mode 100644 docs/cli/session-float-rule.md create mode 100644 docs/cli/session-float-rules.md create mode 100644 docs/usage/rules.md create mode 100644 komorebi/tests/rule_commands.rs create mode 100644 komorebi/tests/rules.rs diff --git a/Cargo.lock b/Cargo.lock index d269c192..5bc70386 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "nanoid", "parking_lot", "pastey", + "regex", "schemars 1.2.3", "serde", "serde_json_lenient", @@ -1517,6 +1518,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" diff --git a/README.md b/README.md index 81f544b5..5a6a1b70 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,10 @@ the window that has the focus. `minimize` and `close` ask KWin to minimize or close the focused window, `unmanage` takes it out of the layout until `manage` takes it back, and `toggle-pause` stops the daemon acting on events and orders until it is resumed. The padding commands set the gaps around the tiles, and -the work area offsets keep parts of a monitor free of tiles. +the work area offsets keep parts of a monitor free of tiles. Rules decide which +windows are managed at all: `ignore-rule` keeps an application out of the +layout, `manage-rule` tiles it whatever the ignore rules say, and +`session-float-rule` floats the focused window and the windows like it. A program such as a status bar can subscribe to the daemon's notifications: it listens on a socket of its own, and after each event and each message, the diff --git a/docs/cli/clear-session-float-rules.md b/docs/cli/clear-session-float-rules.md new file mode 100644 index 00000000..698c8f51 --- /dev/null +++ b/docs/cli/clear-session-float-rules.md @@ -0,0 +1,10 @@ +```text +Clear all session float rules + +Usage: clear-session-float-rules + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/global-state.md b/docs/cli/global-state.md new file mode 100644 index 00000000..2db19928 --- /dev/null +++ b/docs/cli/global-state.md @@ -0,0 +1,10 @@ +```text +Show a JSON representation of the current global state + +Usage: global-state + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/ignore-rule.md b/docs/cli/ignore-rule.md new file mode 100644 index 00000000..832fa7a8 --- /dev/null +++ b/docs/cli/ignore-rule.md @@ -0,0 +1,21 @@ +```text +Add a rule to ignore the specified application + +Usage: ignore-rule + +Arguments: + + Possible values: + - exe: Executable name + - class: Role or subrole + - title: Window title + - path: Executable path + + + Identifier as a string + +Options: + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/docs/cli/manage-rule.md b/docs/cli/manage-rule.md new file mode 100644 index 00000000..7ded06f9 --- /dev/null +++ b/docs/cli/manage-rule.md @@ -0,0 +1,21 @@ +```text +Add a rule to always manage the specified application + +Usage: manage-rule + +Arguments: + + Possible values: + - exe: Executable name + - class: Role or subrole + - title: Window title + - path: Executable path + + + Identifier as a string + +Options: + -h, --help + Print help (see a summary with '-h') + +``` diff --git a/docs/cli/session-float-rule.md b/docs/cli/session-float-rule.md new file mode 100644 index 00000000..dbe3f86b --- /dev/null +++ b/docs/cli/session-float-rule.md @@ -0,0 +1,10 @@ +```text +Add a rule to float the foreground window for the rest of this session + +Usage: session-float-rule + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/session-float-rules.md b/docs/cli/session-float-rules.md new file mode 100644 index 00000000..9e741f33 --- /dev/null +++ b/docs/cli/session-float-rules.md @@ -0,0 +1,10 @@ +```text +Show all session float rules + +Usage: session-float-rules + +Options: + -h, --help + Print help + +``` diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 276a3d20..22c68945 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -29,6 +29,9 @@ its place here. itself: a command asks it to manage the focused window, or to stop managing it. - **Message**: what a client sends the daemon on its socket, a [`SocketMessage`]: an order (focus a window, move it, change the layout, resize it, retile, stop) or a question (the state, the monitors, one [`StateQuery`]). +- **Rule**: a condition on a window's title, executable, class or path ([`MatchingRule`]), in + one of the process-wide lists that decide whether a window is managed (the ignore and the + manage rules) and whether it floats when it opens (the float rules). - **Direction**: where an order goes from the focused container: a side of its tile ([`OperationDirection`]), or the next or the previous container of the workspace ([`CycleDirection`]). @@ -108,9 +111,9 @@ tile is clamped to the work area, so it cannot reach onto a neighboring monitor. The [`WindowManager`] keeps the monitors, the windows the user minimized (they are in no workspace until they are restored), and an index from each window it manages to the monitor and workspace that have it. It is loaded from the backend at startup: every -monitor, and every open window that is normal, alive and not minimized, on the focused -workspace of the monitor its middle is on. After that it changes only when an event comes -in, and each event ends with the workspace in front tiled again. +monitor, and every open window that is normal, alive, not minimized and not ignored by a +rule, on the focused workspace of the monitor its middle is on. After that it changes only +when an event comes in, and each event ends with the workspace in front tiled again. ## The KWin backend @@ -324,9 +327,25 @@ area, and its id goes into a list of unmanaged windows that `handle_event` check on any other event about a window; a `Manage` takes the id out of the list and is handled like a `Show`. A `Destroy` takes the id out of the list too. +Whether a window is managed is [`Window::should_manage`]: the window has to be alive and normal, +and then the rules decide ([`should_act`]). An ignore rule that matches keeps the window out +unless a manage rule matches it too. The float rules do not decide whether a window is managed, +but a `Show` puts a window one matches in the workspace's floating windows instead of a container +(a `Manage` does not ask them). The rules live in process-wide lists in `lib.rs`: +`IgnoreRule` and `ManageRule` add a rule that names an application by one of the four +([`ApplicationIdentifier`]), and take the windows that it names exactly out of the containers of +the workspaces in front; `SessionFloatRule` adds a float rule made of the focused window's +executable, title and class, all three to be equal, and floats it, and `ClearSessionFloatRules` +takes those rules out again. `GlobalState` answers with the ignore and the manage rules +([`GlobalState`]), and `DebugWindow` with what [`Window::should_manage`] read of a window and +which rules matched ([`RuleDebug`]). A rule decides when a window is shown; the bridge reports +a title change (`caption`), which is a `TitleUpdate` event, and a `TitleUpdate` about a window +the index does not have, and that is not minimized, is sent again as a `Show`, so that a rule +about the title it has now decides. + `TogglePause` sets [`WindowManager::is_paused`]: while it is set, `handle_event` returns at once -for every event, and [`read_commands_uds`] hands only `TogglePause`, `State` and `Stop` to the -window manager. `UnmanagedWindowOperationBehaviour` sets +for every event, and [`read_commands_uds`] hands only `TogglePause`, `State`, `GlobalState` and +`Stop` to the window manager. `UnmanagedWindowOperationBehaviour` sets [`WindowManager::unmanaged_window_operation_behaviour`]: with `NoOp`, [`WindowManager::process_command`] refuses every message while the window the compositor has focused is in no container of the focused workspace @@ -402,7 +421,7 @@ module's unit. holding one the same accessors (`containers()`, `focused_container_mut()`, ...). - [`monitor`](../../komorebi/src/monitor.rs), [`workspace`](../../komorebi/src/workspace.rs), [`container`](../../komorebi/src/container.rs), [`window`](../../komorebi/src/window.rs): - the four levels of the tree. + the four levels of the tree; `window` also has the rule engine ([`should_act`]). - [`lockable_sequence`](../../komorebi/src/lockable_sequence.rs): inserting, removing and swapping in a list where some elements are locked in place, which is how a workspace changes its containers. @@ -475,9 +494,9 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | A test's backend does not outlive the test | framework path: [`with_backend`] restores the previous one on drop | `with_backend_puts_the_previous_backend_back_when_the_closure_panics` | none | | A command about a missing window is an error, not a panic | test | `a_command_about_a_window_nobody_opened_is_an_error` and `a_command_about_a_closed_window_is_an_error` against the fake, `a_command_about_a_window_the_table_does_not_know_is_an_error_and_sends_nothing` against the KWin backend | none | | A locked container keeps its index | framework path: a [`Workspace`] inserts, removes and swaps its containers through `LockableSequence` | `test_locked_containers_with_new_window`, `test_locked_containers_remove_window`, `a_window_that_opens_does_not_push_a_locked_container_from_its_index` | `containers_mut()` is the `VecDeque` itself | -| A window komorebi does not manage is never moved | framework path: `handle_event` asks [`Window::should_manage`] before it acts on any event but a destroy | `dialogs_and_other_non_normal_windows_are_left_alone` | none | +| A window komorebi does not manage is never moved | framework path: `handle_event` asks [`Window::should_manage`] before it acts on any event but a destroy | `dialogs_and_other_non_normal_windows_are_left_alone`, `an_ignore_rule_keeps_a_window_unmanaged` | `ManageRule` | | A window the user unmanaged stays out of the layout until it is managed again | framework path: `handle_event` checks the list of unmanaged windows before it acts on any event but a removal | `unmanage_takes_the_focused_window_out_until_manage_takes_it_back` | none | -| A paused window manager moves no window | framework path: `handle_event` returns before anything else, and [`read_commands_uds`] drops every message but `TogglePause`, `State` and `Stop` | `while_paused_no_event_changes_the_layout_and_manage_takes_in_a_window_it_missed`, `while_paused_only_toggle_pause_state_and_stop_are_handled` | none | +| A paused window manager moves no window | framework path: `handle_event` returns before anything else, and [`read_commands_uds`] drops every message but `TogglePause`, `State`, `GlobalState` and `Stop` | `while_paused_no_event_changes_the_layout_and_manage_takes_in_a_window_it_missed`, `while_paused_only_toggle_pause_state_and_stop_are_handled` | none | | A destroyed window leaves whichever workspace has it | framework path: the destroy is looked up in the index of managed windows, which is rebuilt at startup and before and after every event | `a_window_that_was_open_before_komorebi_started_is_removed_when_it_closes` | none | | The daemon does not start without KWin, unless it is told to run against the fake | startup check: [`connect`] fails, and the daemon exits with the error, code 1 | `without_a_session_bus_komorebi_exits_with_an_error_naming_it` | `--fake-backend` | | One komorebi per session | startup check: the session bus gives `org.komorebi.Bridge` to one connection | none | none | @@ -525,7 +544,9 @@ A window is shown, and ends up on a tile: [`WindowManager::process_event`], which hands it to `handle_event` and logs how long that took. The end-to-end tests build the event themselves and call `process_event`. 2. `handle_event` asks [`Window::should_manage`], which asks the backend's `window(id)` - whether the window is alive and normal. If it is not, the event changes nothing. + whether the window is alive and normal, and then the rules whether it is to be managed. If + it is not, the event changes nothing. If a float rule matches it, it goes into the + workspace's floating windows and the steps below do not apply. 3. The index of managed windows is rebuilt from the containers. If the workspace in front already has the window, or the index has it on another monitor or workspace, the event creates nothing. @@ -620,7 +641,9 @@ A window is shown, and ends up on a tile: [`komorebi/tests/manage.rs`](../../komorebi/tests/manage.rs), minimizing, closing, unmanaging and managing the focused window, and pausing; [`komorebi/tests/padding.rs`](../../komorebi/tests/padding.rs), the paddings and the work area - offsets. + offsets; [`komorebi/tests/rules.rs`](../../komorebi/tests/rules.rs) and + [`komorebi/tests/rule_commands.rs`](../../komorebi/tests/rule_commands.rs), the rules and the + messages that add and show them. - [`nix/tests/vm/suites/scrolling`](../../nix/tests/vm/suites/scrolling/test.py): the scrolling strip in a real KWin, with its columns out of view off the screen. - [`nix/tests/vm/suites/commands`](../../nix/tests/vm/suites/commands/test.py): every layout, @@ -645,6 +668,11 @@ A window is shown, and ends up on a tile: [`Container`]: ../../komorebi/src/container.rs [`Window`]: ../../komorebi/src/window.rs [`Window::should_manage`]: ../../komorebi/src/window.rs +[`should_act`]: ../../komorebi/src/window.rs +[`RuleDebug`]: ../../komorebi/src/window.rs +[`GlobalState`]: ../../komorebi/src/state.rs +[`MatchingRule`]: ../../komorebi/src/core/config_generation.rs +[`ApplicationIdentifier`]: ../../komorebi/src/core/mod.rs [`Window::set_position`]: ../../komorebi/src/window.rs [`Workspace::new_container_for_window`]: ../../komorebi/src/workspace.rs [`WindowManager`]: ../../komorebi/src/window_manager.rs diff --git a/docs/index.md b/docs/index.md index d4498d01..77f40c0d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,8 @@ daemon, ask it what it is doing and read its log. window and pause the daemon, and [Padding and work area offsets](usage/padding-and-offsets.md) how to put gaps around the tiles and keep parts of a monitor free of them. +[Rules](usage/rules.md) explains how to keep an application out of the layout, always tile it, or +float its windows. [Subscribing to notifications](usage/subscribing-to-notifications.md) explains how a program such as a status bar follows what komorebi does. The [CLI reference](cli/start.md) has a page for every `komorebic` command, generated from its help. diff --git a/docs/usage/managing-windows.md b/docs/usage/managing-windows.md index f12cba3a..80c3a086 100644 --- a/docs/usage/managing-windows.md +++ b/docs/usage/managing-windows.md @@ -1,7 +1,8 @@ # Managing windows komorebi tiles every normal application window that KWin reports, and leaves dialogs, splash -screens and other windows that are not for tiling alone. The commands here act on the window +screens and other windows that are not for tiling alone, and the windows that [rules](rules.md) +say to ignore. The commands here act on the window that has the focus in KWin, take a window out of the layout or back into it, and pause the daemon. @@ -34,7 +35,7 @@ komorebic manage ``` `komorebic manage` also takes in a window komorebi missed, such as one that opened while the -daemon was paused. The daemon forgets an unmanaged window when it closes, and when the daemon +daemon was paused, but not a window an ignore rule covers. The daemon forgets an unmanaged window when it closes, and when the daemon stops. ## Commands while the focused window is not tiled @@ -58,8 +59,8 @@ sets the behavior back to `op`: give the focus to a tiled window first. [`komorebic toggle-pause`](../cli/toggle-pause.md) pauses the daemon, and the same command resumes it. While it is paused, komorebi does not act on what KWin reports, so it moves no -window, and of its commands it carries out only `toggle-pause`, `state` and `stop`. The daemon's -state says whether it is paused, as `is_paused`. +window, and of its commands it carries out only `toggle-pause`, `state`, `global-state` and +`stop`. The daemon's state says whether it is paused, as `is_paused`. ```sh komorebic toggle-pause diff --git a/docs/usage/rules.md b/docs/usage/rules.md new file mode 100644 index 00000000..9bd34a9b --- /dev/null +++ b/docs/usage/rules.md @@ -0,0 +1,71 @@ +# Rules + +A rule names an application by one of four things about its windows, and says what komorebi does +with them: + +- `exe`: the name of the program's executable, such as `kate`; +- `class`: the app id of a Wayland window, such as `org.kde.kate`, or the class of an X11 window; +- `title`: the title of the window; +- `path`: the full path of the executable, such as `/usr/bin/kate`. + +[`komorebic state`](../cli/state.md) shows them for every window komorebi has, as the `title`, +`exe`, `class` and `path` of its `details`. + +## Ignoring an application + +[`komorebic ignore-rule`](../cli/ignore-rule.md) makes komorebi leave the windows of an +application alone. A rule given this way matches a title or a class that starts or ends with what +it names, and an executable or a path that is exactly what it names. The windows it matches are +not tiled when they open, and the tiled windows of the workspaces in front whose executable, +class, title or path is exactly what it names leave the layout. + +```sh +komorebic ignore-rule exe steam +komorebic ignore-rule class org.kde.kcalc +komorebic ignore-rule title "Picture-in-Picture" +``` + +`komorebic float-rule` is the same command. Plasma's desktop and panels (`plasmashell`) and +KRunner are ignored from the start. + +[`komorebic manage-rule`](../cli/manage-rule.md) makes komorebi tile the windows of an +application that an ignore rule covers, from the next one that opens. + +```sh +komorebic manage-rule class org.kde.plasmashell +``` + +[`komorebic manage`](managing-windows.md#taking-a-window-out-of-the-layout) does not take in a +window an ignore rule covers: a manage rule does. + +## When a rule decides + +A rule decides when a window opens. Applications often open a window before they give it its +title, so the title a rule sees then can be a placeholder. When the title of a window komorebi +does not have changes, komorebi asks the rules again, so a window that a title rule ignored while +it had its first title is tiled once its title no longer matches. A window komorebi tiles stays +tiled whatever its title becomes. + +## Floating the windows of an application + +[`komorebic session-float-rule`](../cli/session-float-rule.md) takes the focused window out of +the layout into the floating windows of its workspace, and adds a rule that floats every window +that opens with the same executable, class and title. +[`komorebic session-float-rules`](../cli/session-float-rules.md) shows those rules, and +[`komorebic clear-session-float-rules`](../cli/clear-session-float-rules.md) removes them: the +windows that float already stay floating. + +```sh +komorebic session-float-rule +komorebic session-float-rules +komorebic clear-session-float-rules +``` + +A window that a float rule matches floats when it opens, and +[`komorebic toggle-float`](floating-windows.md) puts one in the layout. + +## How long rules last + +The rules given with these commands last until the daemon stops. +[`komorebic global-state`](../cli/global-state.md) shows the ignore and the manage rules the daemon +has, and its data directory. diff --git a/komorebi/Cargo.toml b/komorebi/Cargo.toml index 674252c1..fe19b180 100644 --- a/komorebi/Cargo.toml +++ b/komorebi/Cargo.toml @@ -17,6 +17,7 @@ lazy_static = { workspace = true } nanoid = "0.4" parking_lot = "0.12" pastey = { workspace = true } +regex = "1" schemars = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index d6701e68..dfcddc89 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -391,7 +391,18 @@ pub enum SocketMessage { WorkspaceWorkAreaOffset(usize, usize, Rect), ToggleWindowBasedWorkAreaOffset, ResizeDelta(i32), + SessionFloatRule, + SessionFloatRules, + ClearSessionFloatRules, + #[serde(alias = "FloatRule")] + IgnoreRule(ApplicationIdentifier, String), + ManageRule(ApplicationIdentifier, String), + // IdentifyObjectNameChangeApplication(ApplicationIdentifier, String), + // IdentifyTrayApplication(ApplicationIdentifier, String), + // IdentifyLayeredApplication(ApplicationIdentifier, String), + // IdentifyBorderOverflowApplication(ApplicationIdentifier, String), State, + GlobalState, MonitorInformation, Query(StateQuery), MouseFollowsFocus(bool), @@ -403,6 +414,7 @@ pub enum SocketMessage { RemoveSubscriberSocket(String), // AddSubscriberPipe(String), // RemoveSubscriberPipe(String), + DebugWindow(u32), } impl SocketMessage { diff --git a/komorebi/src/desktop.rs b/komorebi/src/desktop.rs index 65eee012..20444314 100644 --- a/komorebi/src/desktop.rs +++ b/komorebi/src/desktop.rs @@ -40,6 +40,7 @@ use crate::backend::backend; use crate::container::Container; use crate::core::Rect; use crate::monitor::Monitor; +use crate::window::RuleDebug; use crate::window::Window; use crate::window_manager::WindowManager; use color_eyre::eyre; @@ -144,7 +145,8 @@ pub fn load_monitor_information(wm: &mut WindowManager) -> eyre::Result<()> { /// monitor they are on, so that starting komorebi tiles what is on screen. /// /// A window is on the monitor whose bounds contain its middle. A window that is minimized, that -/// komorebi does not manage ([`Window::should_manage`]), or that is on no monitor is left alone. +/// komorebi does not manage ([`Window::should_manage`]: not a normal window, or one the rules +/// ignore), or that is on no monitor is left alone. /// Nothing is moved: tiling is [`WindowManager::retile_all`]'s job. /// /// # Errors @@ -192,7 +194,8 @@ pub fn load_workspace_information(wm: &mut WindowManager) -> eyre::Result<()> { } let window = Window::from(info.id); - if !window.should_manage() { + let mut rule_debug = RuleDebug::default(); + if !window.should_manage(None, &mut rule_debug)? { continue; } diff --git a/komorebi/src/kwin/bridge.js b/komorebi/src/kwin/bridge.js index 2c4cab02..3c7121d3 100644 --- a/komorebi/src/kwin/bridge.js +++ b/komorebi/src/kwin/bridge.js @@ -44,8 +44,9 @@ /** * What the bridge sends, as the one argument of `Event`. `emit` adds `seq` to each, which counts * them from 1. `snapshot` is everything on the desktop, sent once, when the script starts; an - * `activated` with no `id` means no window has the focus; `cursor` answers a cursor query, and - * `failed` gives back the commands of a batch that could not be carried out. + * `activated` with no `id` means no window has the focus; `caption` is a window's new title; + * `cursor` answers a cursor query, and `failed` gives back the commands of a batch that could not + * be carried out. * * @typedef {{ * type: "snapshot", windows: WindowInfo[], screens: Screen[], active: string | null, @@ -56,9 +57,10 @@ * @typedef {{ type: "activated", id: string | null }} Activated * @typedef {{ type: "minimized", id: string, v: boolean }} Minimized * @typedef {{ type: "geometry", id: string, geo: Rect }} Geometry + * @typedef {{ type: "caption", id: string, caption: string }} Caption * @typedef {{ type: "cursor", token: number, x: number, y: number }} CursorAnswer * @typedef {{ type: "failed", batch: number, cmds: Command[] }} Failed - * @typedef {Snapshot | Added | Removed | Activated | Minimized | Geometry | CursorAnswer | Failed} BridgeEvent + * @typedef {Snapshot | Added | Removed | Activated | Minimized | Geometry | Caption | CursorAnswer | Failed} BridgeEvent */ /** @@ -267,6 +269,9 @@ function track(w, announce) { w.minimizedChanged.connect(function () { emit("minimized", { id: id, v: !!w.minimized }); }); + w.captionChanged.connect(function () { + emit("caption", { id: id, caption: String(w.caption) }); + }); } workspace.windowAdded.connect(function (w) { diff --git a/komorebi/src/kwin/bridge.test.js b/komorebi/src/kwin/bridge.test.js index 2d51ddae..d8abd4b0 100644 --- a/komorebi/src/kwin/bridge.test.js +++ b/komorebi/src/kwin/bridge.test.js @@ -86,6 +86,7 @@ function window({ desktops: [], frameGeometryChanged: new Signal(), minimizedChanged: new Signal(), + captionChanged: new Signal(), closeWindow: () => assert.fail("a window that is in no KWin was closed"), ...rest, }; @@ -347,6 +348,21 @@ test("a window maximized one way only is not maximized", () => { ); }); +test("a title change is reported with the title the window has now", () => { + const w = window({ id: "{beta}", caption: "beta" }); + const k = kwin({ windows: [w] }); + + w.caption = "beta-renamed"; + w.captionChanged.emit(); + + assert.deepStrictEqual(k.events().at(-1), { + type: "caption", + seq: 2, + id: "{beta}", + caption: "beta-renamed", + }); +}); + test("every call goes to the daemon's names, with one JSON string", () => { const k = kwin(); k.answer([]); diff --git a/komorebi/src/kwin/kwin-api.d.ts b/komorebi/src/kwin/kwin-api.d.ts index 4b1e077e..18e4e6c9 100644 --- a/komorebi/src/kwin/kwin-api.d.ts +++ b/komorebi/src/kwin/kwin-api.d.ts @@ -85,6 +85,7 @@ interface KWinWindow { readonly desktops: VirtualDesktop[]; readonly frameGeometryChanged: Signal<[oldGeometry: QRectF]>; readonly minimizedChanged: Signal; + readonly captionChanged: Signal; closeWindow(): void; } diff --git a/komorebi/src/kwin/state.rs b/komorebi/src/kwin/state.rs index 2bd92a99..8324d6d1 100644 --- a/komorebi/src/kwin/state.rs +++ b/komorebi/src/kwin/state.rs @@ -330,6 +330,7 @@ impl Table { /// with no `id`, took it away from every window. /// - `minimized`: a window was minimized or brought back ([`WindowManagerEvent::Minimize`], /// [`WindowManagerEvent::Restore`]). + /// - `caption`: a window's title changed ([`WindowManagerEvent::TitleUpdate`]). /// - `geometry`: a window's frame changed. It raises no event. /// - `cursor`: the answer to a cursor query ([`Output::Cursor`]). /// - `failed`: commands the script could not carry out. They are logged. @@ -345,6 +346,7 @@ impl Table { Some("removed") => self.on_removed(event, now), Some("activated") => self.on_activated(event), Some("minimized") => self.on_minimized(event), + Some("caption") => self.on_caption(event), Some("geometry") => { if let Some(entry) = self.entry_mut(event) { entry.info.rect = rect(&event["geo"]); @@ -514,6 +516,26 @@ impl Table { WindowManagerEvent::Restore(kwin(KWinNotification::WindowRestored), window) })] } + + fn on_caption(&mut self, event: &Value) -> Vec { + let Some(id) = self.id_of(event) else { + return vec![]; + }; + + let Some(entry) = self.windows.get_mut(&id) else { + return vec![]; + }; + entry.info.title = event["caption"].as_str().unwrap_or_default().to_string(); + + if entry.info.normal { + vec![Output::Event(WindowManagerEvent::TitleUpdate( + kwin(KWinNotification::CaptionChanged), + Window::from(id), + ))] + } else { + vec![] + } + } } /// The executable of a process, by name and by path. @@ -858,6 +880,18 @@ mod tests { ); } + #[test] + fn a_title_change_raises_a_title_update() { + let mut t = table(vec![win("a", &["d1"])]); + let out = t.handle( + &json!({ "type": "caption", "id": "a", "caption": "renamed" }), + Instant::now(), + ); + + assert_eq!(events(&out), vec!["TitleUpdate 1"]); + assert_eq!(t.window(1).unwrap().title, "renamed"); + } + #[test] fn a_cursor_query_answer_is_passed_on_and_remembered() { let mut t = table(vec![]); diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index bdfedbb5..3f0e3882 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -21,8 +21,12 @@ //! [`struct@DATA_DIR`]: after each event and each message the window manager handles, //! [`notify_subscribers`] sends every subscriber a [`Notification`] of it, with the state. +use crate::core::ApplicationIdentifier; use crate::core::SocketMessage; use crate::core::SubscribeOptions; +use crate::core::config_generation::IdWithIdentifier; +use crate::core::config_generation::MatchingRule; +use crate::core::config_generation::MatchingStrategy; use crate::state::State; use crate::window::AspectRatio; use crate::window::PredefinedAspectRatio; @@ -30,6 +34,7 @@ use crate::window_manager_event::WindowManagerEvent; use color_eyre::eyre; use lazy_static::lazy_static; use parking_lot::Mutex; +use regex::Regex; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; @@ -95,6 +100,32 @@ lazy_static! { static ref FLOATING_WINDOW_TOGGLE_ASPECT_RATIO: Arc> = Arc::new(Mutex::new( AspectRatio::Predefined(PredefinedAspectRatio::Widescreen) )); + /// The compiled regular expression of each rule whose matching strategy is `Regex`, by the + /// rule's id + static ref REGEX_IDENTIFIERS: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + /// Windows that are tiled even when an ignore rule covers them (`ManageRule`) + static ref MANAGE_IDENTIFIERS: Arc>> = Arc::new(Mutex::new(vec![])); + /// Windows that are never tiled, unless a manage rule says otherwise. Most of what would belong + /// here (the panel, krunner, popups) is not a normal window and is filtered before rules run. + pub static ref IGNORE_IDENTIFIERS: Arc>> = Arc::new(Mutex::new( + ["plasmashell", "org.kde.plasmashell", "krunner", "org.kde.krunner"] + .into_iter() + .map(|class| MatchingRule::Simple(IdWithIdentifier { + kind: ApplicationIdentifier::Class, + id: String::from(class), + matching_strategy: Option::from(MatchingStrategy::Equals), + })) + .collect() + )); + /// The float rules `SessionFloatRule` added, which `ClearSessionFloatRules` takes out of + /// [`struct@FLOATING_APPLICATIONS`] again + static ref SESSION_FLOATING_APPLICATIONS: Arc>> = + Arc::new(Mutex::new(Vec::new())); + /// Windows that are managed as floating windows from the moment they open + static ref FLOATING_APPLICATIONS: Arc>> = Arc::new(Mutex::new(vec![])); + /// Window classes that are never managed, whatever the rules say + static ref PERMAIGNORE_CLASSES: Arc>> = Arc::new(Mutex::new(vec![])); /// The windows `UnmanageFocusedWindow` took out of the layout: komorebi leaves them alone /// until `ManageFocusedWindow` takes them back, or until they close static ref UNMANAGED_WINDOW_IDS: Arc>> = Arc::new(Mutex::new(vec![])); diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 1984fd7b..2485ed96 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -9,6 +9,11 @@ //! - **State**: the window manager's [`State`], as JSON. //! - **MonitorInformation**: the id, the name and the bounds of each monitor, as JSON. //! - **Query**: one value as text, the answer to a [`StateQuery`]. +//! - **GlobalState**: the ignore and the manage rules and the data directory ([`GlobalState`]), as +//! JSON. +//! - **SessionFloatRules**: the float rules **SessionFloatRule** added, as JSON. +//! - **DebugWindow**: what [`Window::should_manage`] read of a window and which rules matched it +//! ([`RuleDebug`]), as JSON, or nothing for a window that is not there. //! //! The others answer nothing: //! @@ -64,8 +69,13 @@ //! - **UnmanagedWindowOperationBehaviour** sets whether a message is carried out while the window //! that has the focus is not tiled ([`WindowManager::handle_unmanaged_window_behaviour`]). With //! `NoOp`, every message is refused then, this one included. +//! - **IgnoreRule** adds a rule that keeps the windows of an application out of the layout, and +//! takes the ones it names exactly out of the workspaces in front; **ManageRule** adds a rule +//! that has them tiled whatever the ignore rules say. **SessionFloatRule** floats the window +//! that has the focus and adds a rule that floats the windows that open with its executable, +//! title and class, and **ClearSessionFloatRules** removes those rules. //! - **TogglePause** pauses the window manager, or resumes it: while it is paused, it handles no -//! event, and of the messages only **TogglePause**, **State** and **Stop** +//! event, and of the messages only **TogglePause**, **State**, **GlobalState** and **Stop** //! ([`read_commands_uds`]). //! - **ResizeWindowEdge** and **ResizeWindowAxis** resize the focused container by the //! [`WindowManager::resize_delta`] that **ResizeDelta** sets ([`WindowManager::resize_window`]), @@ -81,11 +91,16 @@ //! ([`notify_subscribers`]). use crate::DATA_DIR; +use crate::FLOATING_APPLICATIONS; +use crate::IGNORE_IDENTIFIERS; +use crate::MANAGE_IDENTIFIERS; use crate::Notification; use crate::NotificationEvent; +use crate::SESSION_FLOATING_APPLICATIONS; use crate::SUBSCRIPTION_SOCKET_OPTIONS; use crate::SUBSCRIPTION_SOCKETS; use crate::backend::backend; +use crate::core::ApplicationIdentifier; use crate::core::Axis; use crate::core::Layout; use crate::core::LayoutOptions; @@ -94,9 +109,14 @@ use crate::core::Rect; use crate::core::ScrollingLayoutOptions; use crate::core::SocketMessage; use crate::core::StateQuery; +use crate::core::config_generation::IdWithIdentifier; +use crate::core::config_generation::MatchingRule; +use crate::core::config_generation::MatchingStrategy; use crate::monitor::MonitorInformation; use crate::notify_subscribers; +use crate::state::GlobalState; use crate::state::State; +use crate::window::RuleDebug; use crate::window::Window; use crate::window_manager::WindowManager; use color_eyre::eyre; @@ -185,8 +205,10 @@ impl WindowManager { /// - The monitor or the workspace of `WorkspacePadding` or `ContainerPadding` is not there, or /// the focused workspace has no padding of its own for `AdjustWorkspacePadding` or /// `AdjustContainerPadding` to change. - /// - `Minimize`, `Close`, `ManageFocusedWindow` or `UnmanageFocusedWindow` when the compositor - /// reports no focused window, or the backend refuses to minimize or close it. + /// - `Minimize`, `Close`, `ManageFocusedWindow`, `UnmanageFocusedWindow` or `SessionFloatRule` + /// when the compositor reports no focused window, or the backend refuses to minimize, close or + /// float it. + /// - `IgnoreRule` cannot take a window out of a workspace, or tile the workspace again. /// - The file of `QuickSave` or `Save` cannot be written, or the file of `QuickLoad` or /// `Load` cannot be read or is not a list of resize adjustments. /// - The window manager fails to retile, for one of the reasons @@ -407,6 +429,18 @@ impl WindowManager { tracing::info!("replying to state done"); } + SocketMessage::GlobalState => { + let state = match serde_json::to_string_pretty(&GlobalState::default()) { + Ok(state) => state, + Err(error) => error.to_string(), + }; + + tracing::info!("replying to global state"); + + reply.write_all(state.as_bytes())?; + + tracing::info!("replying to global state done"); + } SocketMessage::MonitorInformation => { let mut monitors = vec![]; for monitor in self.monitors() { @@ -461,6 +495,148 @@ impl WindowManager { reply.write_all(response.as_bytes())?; } + SocketMessage::SessionFloatRule => { + let foreground_window = Window::from( + backend() + .foreground_window() + .ok_or_eyre("there is no foreground window")?, + ); + let (exe, title, class) = ( + foreground_window.exe(), + foreground_window.title(), + foreground_window.class(), + ); + + if let (Some(exe), Some(title), Some(class)) = (exe, title, class) { + let rule = MatchingRule::Composite(vec![ + IdWithIdentifier { + kind: ApplicationIdentifier::Exe, + id: exe, + matching_strategy: Option::from(MatchingStrategy::Equals), + }, + IdWithIdentifier { + kind: ApplicationIdentifier::Title, + id: title, + matching_strategy: Option::from(MatchingStrategy::Equals), + }, + IdWithIdentifier { + kind: ApplicationIdentifier::Class, + id: class, + matching_strategy: Option::from(MatchingStrategy::Equals), + }, + ]); + + let mut floating_applications = FLOATING_APPLICATIONS.lock(); + floating_applications.push(rule.clone()); + let mut session_floating_applications = SESSION_FLOATING_APPLICATIONS.lock(); + session_floating_applications.push(rule.clone()); + + self.toggle_float(true)?; + } + } + SocketMessage::SessionFloatRules => { + let session_floating_applications = SESSION_FLOATING_APPLICATIONS.lock(); + let rules = serde_json::to_string_pretty(&*session_floating_applications) + .unwrap_or_else(|error| error.to_string()); + + reply.write_all(rules.as_bytes())?; + } + SocketMessage::ClearSessionFloatRules => { + let mut floating_applications = FLOATING_APPLICATIONS.lock(); + let mut session_floating_applications = SESSION_FLOATING_APPLICATIONS.lock(); + floating_applications.retain(|r| !session_floating_applications.contains(r)); + session_floating_applications.clear() + } + SocketMessage::IgnoreRule(identifier, ref id) => { + let mut ignore_identifiers = IGNORE_IDENTIFIERS.lock(); + + let mut should_push = true; + for i in &*ignore_identifiers { + if let MatchingRule::Simple(i) = i + && i.id.eq(id) + { + should_push = false; + } + } + + if should_push { + ignore_identifiers.push(MatchingRule::Simple(IdWithIdentifier { + kind: identifier, + id: id.clone(), + matching_strategy: Option::from(MatchingStrategy::Legacy), + })); + } + + let offset = self.work_area_offset; + + let mut window_ids_to_purge = vec![]; + for (i, monitor) in self.monitors().iter().enumerate() { + for container in monitor + .focused_workspace() + .ok_or_eyre("there is no workspace")? + .containers() + { + for window in container.windows() { + match identifier { + ApplicationIdentifier::Path => { + if window.path().unwrap_or_default().to_string_lossy() == *id { + window_ids_to_purge.push((i, window.id)); + } + } + ApplicationIdentifier::Exe => { + if window.exe().unwrap_or_default() == *id { + window_ids_to_purge.push((i, window.id)); + } + } + ApplicationIdentifier::Class => { + if window.class().unwrap_or_default() == *id { + window_ids_to_purge.push((i, window.id)); + } + } + ApplicationIdentifier::Title => { + if window.title().unwrap_or_default() == *id { + window_ids_to_purge.push((i, window.id)); + } + } + } + } + } + } + + for (monitor_idx, id) in window_ids_to_purge { + let monitor = self + .monitors_mut() + .get_mut(monitor_idx) + .ok_or_eyre("there is no monitor")?; + + monitor + .focused_workspace_mut() + .ok_or_eyre("there is no focused workspace")? + .remove_window(id)?; + + monitor.update_focused_workspace(offset)?; + } + } + SocketMessage::ManageRule(identifier, ref id) => { + let mut manage_identifiers = MANAGE_IDENTIFIERS.lock(); + + let mut should_push = true; + for m in &*manage_identifiers { + if let MatchingRule::Simple(m) = m + && m.id.eq(id) + { + should_push = false; + } + } + + if should_push { + manage_identifiers.push(MatchingRule::Simple(IdWithIdentifier { + kind: identifier, + id: id.clone(), + matching_strategy: Option::from(MatchingStrategy::Legacy), + })); + } + } SocketMessage::ContainerPadding(monitor_idx, workspace_idx, size) => { self.set_container_padding(monitor_idx, workspace_idx, size)?; } @@ -641,6 +817,16 @@ impl WindowManager { workspace.resize_dimensions = resize; self.update_focused_workspace(false, false)?; } + SocketMessage::DebugWindow(window_id) => { + let window = Window::from(window_id); + if window.is_valid() { + let mut rule_debug = RuleDebug::default(); + let _ = window.should_manage(None, &mut rule_debug); + let schema = serde_json::to_string_pretty(&rule_debug)?; + + reply.write_all(schema.as_bytes())?; + } + } } self.update_known_window_ids(); @@ -664,8 +850,8 @@ impl WindowManager { /// /// A message that arrives while the window manager has been locked for a second is logged and /// dropped. While the window manager is paused, the first message that is not `TogglePause`, -/// `State` or `Stop` is dropped, and the first that is one of them is handled; either way, the -/// lines after it are not read. +/// `State`, `GlobalState` or `Stop` is dropped, and the first that is one of them is handled; +/// either way, the lines after it are not read. /// /// # Errors /// @@ -693,9 +879,10 @@ pub fn read_commands_uds( Some(mut wm) => { if wm.is_paused { return match message { - SocketMessage::TogglePause | SocketMessage::State | SocketMessage::Stop => { - Ok(wm.process_command(message, &mut stream)?) - } + SocketMessage::TogglePause + | SocketMessage::State + | SocketMessage::GlobalState + | SocketMessage::Stop => Ok(wm.process_command(message, &mut stream)?), _ => { tracing::trace!("ignoring while paused"); Ok(()) diff --git a/komorebi/src/process_event.rs b/komorebi/src/process_event.rs index 8b052d74..fc017144 100644 --- a/komorebi/src/process_event.rs +++ b/komorebi/src/process_event.rs @@ -1,12 +1,14 @@ //! How the window manager answers what the compositor reports: [`WindowManager::process_event`]. //! //! Nothing changes while the window manager is paused (`TogglePause`). An event about a window -//! komorebi does not manage ([`Window::should_manage`]), or about one the user took out of the -//! layout with `UnmanageFocusedWindow`, changes nothing either. Otherwise: +//! komorebi does not manage ([`Window::should_manage`]: a dialog, or a window an ignore rule +//! covers and no manage rule rescues), or about one the user took out of the layout with +//! `UnmanageFocusedWindow`, changes nothing either. Otherwise: //! //! - **Show**, and **Manage** (`ManageFocusedWindow`): the window gets a container of its own after //! the focused one, and the workspace is tiled again. The first window on an empty workspace is -//! given the focus. +//! given the focus. A window a float rule matches goes into the workspace's floating windows +//! instead, on a Show (a Manage tiles it whatever the rules say). //! - **Destroy**, and **Unmanage** (`UnmanageFocusedWindow`): the window is taken out of whichever //! workspace has it, and if that is the one in front it is tiled again. A window that is //! unmanaged stays open, centered in the work area so that it is not left behind the tiles, and @@ -16,6 +18,8 @@ //! that instead, so that the strip scrolls to the window that has the focus. //! - **Minimize**: a window the user minimized leaves its workspace and the rest are tiled //! again; **Restore** brings it back in a container of its own. +//! - **TitleUpdate**: a window komorebi does not have, and that was not minimized, is announced +//! again as a Show, so that a rule that matches on the title it has now decides. //! //! The daemon hands events to the window manager on a thread of their own, //! [`listen_for_events`], one at a time and in the order the backend raised them. @@ -25,16 +29,23 @@ //! //! [`Window::should_manage`]: crate::window::Window::should_manage +use crate::FLOATING_APPLICATIONS; use crate::Notification; use crate::NotificationEvent; +use crate::REGEX_IDENTIFIERS; use crate::UNMANAGED_WINDOW_IDS; use crate::backend::backend; use crate::core::DefaultLayout; use crate::core::Layout; use crate::notify_subscribers; use crate::state::State; +use crate::window::RuleDebug; +use crate::window::should_act; use crate::window_manager::WindowManager; +use crate::window_manager_event::KWinNotification; +use crate::window_manager_event::SystemNotification; use crate::window_manager_event::WindowManagerEvent; +use crate::window_manager_event_listener; use color_eyre::eyre; use parking_lot::Mutex; use std::sync::Arc; @@ -101,15 +112,17 @@ impl WindowManager { }; let window_id = window.id; - // A destroyed window can no longer be asked whether it should be managed, and a window the - // user unmanaged is let go of whatever it is, so a removal is let through without asking + // A destroyed window can no longer be asked whether it should be managed, and rules must + // not stop us dropping a window we do manage, so this is settled before the rules are let is_removal = matches!( event, WindowManagerEvent::Destroy(_, _) | WindowManagerEvent::Unmanage(_) ); + let mut rule_debug = RuleDebug::default(); let should_manage = is_removal - || (window.should_manage() && !UNMANAGED_WINDOW_IDS.lock().contains(&window_id)); + || (window.should_manage(Some(event), &mut rule_debug)? + && !UNMANAGED_WINDOW_IDS.lock().contains(&window_id)); if !should_manage { tracing::debug!("ignoring event as window should not be managed: {window}"); @@ -182,14 +195,65 @@ impl WindowManager { } if create { + let behaviour = self.window_management_behaviour; let workspace = self.focused_workspace_mut()?; - workspace.new_container_for_window(&window)?; - self.update_focused_workspace(false, false)?; - if self.focused_workspace()?.containers().len() == 1 { - // If after adding this window the workspace only contains 1 window, it - // was previously empty, so give this window the focus - window.focus(self.mouse_follows_focus)?; + let floating_applications = FLOATING_APPLICATIONS.lock(); + let mut should_float = false; + + if !floating_applications.is_empty() { + let regex_identifiers = REGEX_IDENTIFIERS.lock(); + + if let (Some(title), Some(exe_name), Some(class), Some(path)) = + (window.title(), window.exe(), window.class(), window.path()) + { + should_float = should_act( + &title, + &exe_name, + &[&class], + &path.to_string_lossy(), + &floating_applications, + ®ex_identifiers, + ) + .is_some(); + } + } + drop(floating_applications); + + if behaviour.float_override + || behaviour.floating_layer_override + || (should_float && !matches!(event, WindowManagerEvent::Manage(_))) + { + let placement = if behaviour.floating_layer_override { + // Floating layer override placement + behaviour.floating_layer_placement + } else if behaviour.float_override { + // Float override placement + behaviour.float_override_placement + } else { + // Float rule placement + behaviour.float_rule_placement + }; + // Center floating windows according to the proper placement if not + // on a floating workspace + let center_spawned_floats = placement.should_center() && workspace.tile; + workspace.floating_windows_mut().push_back(window); + if center_spawned_floats { + let mut floating_window = window; + floating_window + .center(&workspace.globals.work_area, placement.should_resize())?; + } + + self.update_focused_workspace(false, false)?; + } else { + workspace.new_container_for_window(&window)?; + self.update_focused_workspace(false, false)?; + + if self.focused_workspace()?.containers().len() == 1 { + // If after adding this window the workspace only contains 1 window, it + // was previously empty, so give this window the focus + window.focus(self.mouse_follows_focus)?; + } } } } @@ -256,6 +320,19 @@ impl WindowManager { self.update_focused_workspace(false, false)?; } }, + WindowManagerEvent::TitleUpdate(_, _) => { + // Windows are routinely announced before their title is set. Now that it is, a rule + // that matches on the title can finally decide, so a window that was not picked up + // gets another chance to be. + if !self.known_window_ids.contains_key(&window_id) + && !self.minimized_windows.contains_key(&window_id) + { + window_manager_event_listener::send_notification(WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + window, + )); + } + } } self.update_known_window_ids(); diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index 2df52fed..6aaf51aa 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -5,11 +5,18 @@ //! subscribers in every [`Notification`]. [`State::has_been_modified`] decides whether an event or //! a message changed it, for the subscribers that want a notification only then. //! +//! [`GlobalState`] is what the daemon has besides the window manager, the rules and the data +//! directory, which it writes in answer to `GlobalState`. +//! //! [`SocketMessage::State`]: crate::core::SocketMessage::State //! [`Notification`]: crate::Notification +use crate::DATA_DIR; +use crate::IGNORE_IDENTIFIERS; +use crate::MANAGE_IDENTIFIERS; use crate::core::OperationBehaviour; use crate::core::Rect; +use crate::core::config_generation::MatchingRule; use crate::monitor::Monitor; use crate::ring::Ring; use crate::window_manager::WindowManager; @@ -17,6 +24,64 @@ use crate::workspace::Workspace; use serde::Deserialize; use serde::Serialize; use std::collections::VecDeque; +use std::path::PathBuf; + +/// What the daemon has that is not part of the window manager, as it answers +/// [`SocketMessage::GlobalState`](crate::core::SocketMessage::GlobalState): the ignore and the manage +/// rules, and the data directory +/// +/// # Example +/// +/// ```rust +/// use komorebi::state::GlobalState; +/// +/// let state = GlobalState::default(); +/// // plasmashell and krunner are ignored from the start +/// let json = serde_json::to_string(&state.ignore_identifiers)?; +/// assert!(json.contains(r#""id":"org.kde.plasmashell""#), "{json}"); +/// assert!(state.manage_identifiers.is_empty()); +/// assert_eq!(state.data_dir, *komorebi::DATA_DIR); +/// +/// # Ok::<(), Box>(()) +/// ``` +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Serialize, Deserialize)] +pub struct GlobalState { + // pub border_enabled: bool, + // pub border_colours: BorderColours, + // pub border_style: BorderStyle, + // pub border_offset: i32, + // pub border_width: i32, + // pub stackbar_mode: StackbarMode, + // pub stackbar_label: StackbarLabel, + // pub stackbar_focused_text_colour: Colour, + // pub stackbar_unfocused_text_colour: Colour, + // pub stackbar_tab_background_colour: Colour, + // pub stackbar_tab_width: i32, + // pub stackbar_height: i32, + // pub remove_titlebars: bool, + // #[serde(alias = "float_identifiers")] + pub ignore_identifiers: Vec, + pub manage_identifiers: Vec, + // pub layered_whitelist: Vec, + // pub tray_and_multi_window_identifiers: Vec, + // pub name_change_on_launch_identifiers: Vec, + // pub monitor_index_preferences: HashMap, + // pub ignored_duplicate_monitor_serial_ids: Vec, + // pub window_hiding_behaviour: HidingBehaviour, + pub data_dir: PathBuf, + // pub custom_ffm: bool, +} + +impl Default for GlobalState { + fn default() -> Self { + Self { + ignore_identifiers: IGNORE_IDENTIFIERS.lock().clone(), + manage_identifiers: MANAGE_IDENTIFIERS.lock().clone(), + data_dir: DATA_DIR.clone(), + } + } +} /// A copy of the window manager's monitors, with their workspaces, containers and windows, and /// of its settings, which serializes to JSON diff --git a/komorebi/src/window.rs b/komorebi/src/window.rs index e51cb942..909167f6 100644 --- a/komorebi/src/window.rs +++ b/komorebi/src/window.rs @@ -5,16 +5,32 @@ //! [`Backend`](crate::backend::Backend) when it is needed, and everything done to it (a move, the //! focus, a close) is a command to the backend. Nothing is cached, so a window that changed its //! title, or closed, is seen as it is now. +//! +//! [`Window::should_manage`] decides whether komorebi manages a window: it has to be a normal +//! application window that is alive, and then the ignore and manage rules decide +//! ([`should_act`], [`should_act_individual`]), writing what they found to a [`RuleDebug`]. +use crate::FLOATING_APPLICATIONS; use crate::FLOATING_WINDOW_TOGGLE_ASPECT_RATIO; +use crate::IGNORE_IDENTIFIERS; +use crate::MANAGE_IDENTIFIERS; +use crate::PERMAIGNORE_CLASSES; +use crate::REGEX_IDENTIFIERS; use crate::backend::backend; +use crate::core::ApplicationIdentifier; use crate::core::Rect; +use crate::core::config_generation::IdWithIdentifier; +use crate::core::config_generation::MatchingRule; +use crate::core::config_generation::MatchingStrategy; +use crate::window_manager_event::WindowManagerEvent; use color_eyre::eyre; use color_eyre::eyre::OptionExt; +use regex::Regex; use serde::Deserialize; use serde::Serialize; use serde::Serializer; use serde::ser::SerializeStruct; +use std::collections::HashMap; use std::fmt::Display; use std::fmt::Formatter; use std::fmt::Write; @@ -224,24 +240,60 @@ impl Window { }) } - /// Whether the window is one komorebi should tile: a window that is still alive and that the + /// Whether the window is one komorebi should tile: a window that is still alive, that the /// compositor considers a normal application window, as opposed to a dialog, a splash screen, - /// a utility window or a dock ([`WindowInfo::normal`](crate::backend::WindowInfo::normal)). + /// a utility window or a dock ([`WindowInfo::normal`](crate::backend::WindowInfo::normal)), + /// and that no ignore rule covers, or that a manage rule rescues /// - /// A window the compositor has no record of is not managed. Windows that are minimized when - /// komorebi starts are not picked up either + /// What was looked at and which rules matched is written to `debug`, which is how + /// `DebugWindow` answers. A window the compositor has no record of is not managed. Windows + /// that are minimized when komorebi starts are not picked up either /// ([`load_workspace_information`](crate::desktop::load_workspace_information)). - pub fn should_manage(&self) -> bool { + /// + /// # Errors + /// + /// None: every path returns `Ok`. + #[tracing::instrument(skip_all)] + pub fn should_manage( + &self, + event: Option, + debug: &mut RuleDebug, + ) -> eyre::Result { let Some(info) = self.info() else { - return false; + return Ok(false); }; if !info.alive { - return false; + return Ok(false); } // dialogs, splash screens, utility windows, docks and the like are not for tiling - info.normal + if !info.normal { + return Ok(false); + } + + debug.is_window = true; + + // windows are often announced before their title is set; a TitleUpdate re-evaluates them + debug.has_title = !info.title.is_empty(); + + debug.title = Some(info.title.clone()); + debug.exe_name = Some(info.exe.clone()); + debug.class = Some(info.class.clone()); + debug.path = Some(info.path.clone()); + + let eligible = window_is_eligible( + self.id, + &info.title, + &info.exe, + &[&info.class], + &info.path, + event, + debug, + ); + + debug.should_manage = eligible; + Ok(eligible) } } @@ -290,6 +342,435 @@ impl AspectRatio { } } +/// Why [`Window::should_manage`] decided as it did: what it read of the window, and the rule of +/// each kind that matched it +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct RuleDebug { + pub should_manage: bool, + pub is_window: bool, + // pub has_minimum_width: bool, + // pub has_minimum_height: bool, + pub has_title: bool, + // pub is_cloaked: bool, + // pub allow_cloaked: bool, + // pub allow_layered_transparency: bool, + // pub window_style: Option, + // pub extended_window_style: Option, + pub title: Option, + pub exe_name: Option, + pub class: Option, + pub path: Option, + pub matches_permaignore_class: Option, + pub matches_ignore_identifier: Option, + pub matches_managed_override: Option, + // pub matches_layered_whitelist: Option, + pub matches_floating_applications: Option, + // pub matches_wsl2_gui: Option, + // pub matches_no_titlebar: Option, +} + +/// Whether the rules let a window be managed: not when its class is one of +/// [`struct@PERMAIGNORE_CLASSES`], and not when an ignore rule matches it and no manage rule +/// does. Every rule that matches is written to `debug`, the float rules too, which do not decide +/// whether the window is managed. +#[allow(clippy::too_many_arguments)] +fn window_is_eligible( + _window_id: u32, + title: &str, + exe_name: &str, + classes: &[&str], + path: &str, + // style: &WindowStyle, + // ex_style: &ExtendedWindowStyle, + _event: Option, + debug: &mut RuleDebug, +) -> bool { + { + let permaignore_classes = PERMAIGNORE_CLASSES.lock(); + for class in classes { + if permaignore_classes.contains(&class.to_string()) { + debug.matches_permaignore_class = Some(class.to_string()); + return false; + } + } + } + + let regex_identifiers = REGEX_IDENTIFIERS.lock(); + + let ignore_identifiers = IGNORE_IDENTIFIERS.lock(); + let should_ignore = if let Some(rule) = should_act( + title, + exe_name, + classes, + path, + &ignore_identifiers, + ®ex_identifiers, + ) { + debug.matches_ignore_identifier = Some(rule); + true + } else { + false + }; + + let manage_identifiers = MANAGE_IDENTIFIERS.lock(); + let managed_override = if let Some(rule) = should_act( + title, + exe_name, + classes, + path, + &manage_identifiers, + ®ex_identifiers, + ) { + debug.matches_managed_override = Some(rule); + true + } else { + false + }; + + let floating_identifiers = FLOATING_APPLICATIONS.lock(); + if let Some(rule) = should_act( + title, + exe_name, + classes, + path, + &floating_identifiers, + ®ex_identifiers, + ) { + debug.matches_floating_applications = Some(rule); + } + + if should_ignore && !managed_override { + return false; + } + + true +} + +/// Returns the last of `identifiers` that matches a window with this title, executable, classes +/// and path, if one does: a simple rule matches when its one condition does +/// ([`should_act_individual`]), and a composite rule when all of its conditions do +#[allow(clippy::cognitive_complexity, clippy::too_many_lines)] +pub fn should_act( + title: &str, + exe_name: &str, + classes: &[&str], + path: &str, + identifiers: &[MatchingRule], + regex_identifiers: &HashMap, +) -> Option { + let mut matching_rule = None; + for rule in identifiers { + match rule { + MatchingRule::Simple(identifier) => { + if should_act_individual( + title, + exe_name, + classes, + path, + identifier, + regex_identifiers, + ) { + matching_rule = Some(rule.clone()); + }; + } + MatchingRule::Composite(identifiers) => { + let mut composite_results = vec![]; + for identifier in identifiers { + composite_results.push(should_act_individual( + title, + exe_name, + classes, + path, + identifier, + regex_identifiers, + )); + } + + if composite_results.iter().all(|&x| x) { + matching_rule = Some(rule.clone()); + } + } + } + } + + matching_rule +} + +/// Returns true if and only if the condition `identifier` holds for a window with this title, +/// executable, classes and path +/// +/// The condition compares the title, the executable, any of the classes or the path with its id, +/// by its matching strategy. Without one, or with `Legacy`, a title or a class matches when it +/// starts or ends with the id, and an executable or a path when it is the id. A `Regex` id is +/// looked up in `regex_identifiers`, and an id with no compiled regex there matches nothing. A +/// `.exe` in the id is ignored. +pub fn should_act_individual( + title: &str, + exe_name: &str, + classes: &[&str], + path: &str, + identifier: &IdWithIdentifier, + regex_identifiers: &HashMap, +) -> bool { + let mut should_act = false; + + let mut identifier = identifier.clone(); + identifier.id = identifier.id.replace(".exe", ""); + + match identifier.matching_strategy { + None | Some(MatchingStrategy::Legacy) => match identifier.kind { + ApplicationIdentifier::Title => { + if title.starts_with(&identifier.id) || title.ends_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if class.starts_with(&identifier.id) || class.ends_with(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if exe_name.eq(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if path.eq(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::Equals) => match identifier.kind { + ApplicationIdentifier::Title => { + if title.eq(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if class.eq(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if exe_name.eq(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if path.eq(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::DoesNotEqual) => match identifier.kind { + ApplicationIdentifier::Title => { + if !title.eq(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if !class.eq(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if !exe_name.eq(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if !path.eq(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::StartsWith) => match identifier.kind { + ApplicationIdentifier::Title => { + if title.starts_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if class.starts_with(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if exe_name.starts_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if path.starts_with(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::DoesNotStartWith) => match identifier.kind { + ApplicationIdentifier::Title => { + if !title.starts_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if !class.starts_with(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if !exe_name.starts_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if !path.starts_with(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::EndsWith) => match identifier.kind { + ApplicationIdentifier::Title => { + if title.ends_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if class.ends_with(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if exe_name.ends_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if path.ends_with(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::DoesNotEndWith) => match identifier.kind { + ApplicationIdentifier::Title => { + if !title.ends_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if !class.ends_with(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if !exe_name.ends_with(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if !path.ends_with(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::Contains) => match identifier.kind { + ApplicationIdentifier::Title => { + if title.contains(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if class.contains(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if exe_name.contains(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if path.contains(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::DoesNotContain) => match identifier.kind { + ApplicationIdentifier::Title => { + if !title.contains(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Class => { + for class in classes { + if !class.contains(&identifier.id) { + should_act = true; + } + } + } + ApplicationIdentifier::Exe => { + if !exe_name.contains(&identifier.id) { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if !path.contains(&identifier.id) { + should_act = true; + } + } + }, + Some(MatchingStrategy::Regex) => match identifier.kind { + ApplicationIdentifier::Title => { + if let Some(re) = regex_identifiers.get(&identifier.id) + && re.is_match(title) + { + should_act = true; + } + } + ApplicationIdentifier::Class => { + if let Some(re) = regex_identifiers.get(&identifier.id) { + for class in classes { + if re.is_match(class) { + should_act = true; + } + } + } + } + ApplicationIdentifier::Exe => { + if let Some(re) = regex_identifiers.get(&identifier.id) + && re.is_match(exe_name) + { + should_act = true; + } + } + ApplicationIdentifier::Path => { + if let Some(re) = regex_identifiers.get(&identifier.id) + && re.is_match(path) + { + should_act = true; + } + } + }, + } + + should_act +} + #[cfg(test)] mod tests { use super::*; @@ -397,10 +878,12 @@ mod tests { fake.close_window_now(207); with_backend(fake, || { - assert!(Window::from(205).should_manage()); - assert!(!Window::from(206).should_manage()); - assert!(!Window::from(207).should_manage()); - assert!(!Window::from(208).should_manage()); + let should_manage = + |id| Window::from(id).should_manage(None, &mut RuleDebug::default()); + assert!(should_manage(205).unwrap()); + assert!(!should_manage(206).unwrap()); + assert!(!should_manage(207).unwrap()); + assert!(!should_manage(208).unwrap()); }); } @@ -416,4 +899,94 @@ mod tests { assert_eq!(json["details"]["path"], "/usr/bin/foot"); Ok(()) } + + fn condition( + kind: ApplicationIdentifier, + id: &str, + matching_strategy: Option, + ) -> IdWithIdentifier { + IdWithIdentifier { + kind, + id: id.to_string(), + matching_strategy, + } + } + + /// Whether `condition` holds for a Kate window editing notes.txt + fn holds_for_kate(condition: &IdWithIdentifier, regexes: &HashMap) -> bool { + should_act_individual( + "notes.txt — Kate", + "kate", + &["org.kde.kate"], + "/usr/bin/kate", + condition, + regexes, + ) + } + + /// Whether the condition holds for the Kate window, with no compiled regex + fn holds(kind: ApplicationIdentifier, id: &str, strategy: Option) -> bool { + holds_for_kate(&condition(kind, id, strategy), &HashMap::new()) + } + + #[test] + fn without_a_strategy_a_title_or_a_class_matches_at_either_end_and_an_executable_whole() { + use ApplicationIdentifier::*; + + assert!(holds(Title, "Kate", None)); + assert!(holds(Title, "notes", None)); + assert!(!holds(Title, "txt", None)); + assert!(holds(Class, "kate", None)); + assert!(holds(Exe, "kate.exe", None)); + assert!(!holds(Exe, "kat", None)); + assert!(holds(Path, "/usr/bin/kate", None)); + } + + #[test] + fn a_strategy_says_how_the_id_is_compared_and_a_regex_needs_its_compiled_form() { + use ApplicationIdentifier::*; + use MatchingStrategy::*; + + assert!(!holds(Class, "kate", Some(Equals))); + assert!(holds(Class, "org.kde.kate", Some(Equals))); + assert!(holds(Title, "txt", Some(Contains))); + assert!(holds(Exe, "foot", Some(DoesNotEqual))); + assert!(holds(Path, "/usr/", Some(StartsWith))); + + let regex = condition(Title, "^notes\\.", Some(Regex)); + assert!(!holds_for_kate(®ex, &HashMap::new())); + let compiled = HashMap::from([( + String::from("^notes\\."), + regex::Regex::new("^notes\\.").unwrap(), + )]); + assert!(holds_for_kate(®ex, &compiled)); + } + + #[test] + fn a_composite_rule_needs_every_condition_and_the_last_rule_that_matches_is_the_one() { + use ApplicationIdentifier::*; + let kate = condition(Class, "org.kde.kate", Some(MatchingStrategy::Equals)); + let notes = condition(Title, "notes", None); + let other = condition(Title, "other", None); + let rules = [ + MatchingRule::Composite(vec![kate.clone(), other]), + MatchingRule::Simple(kate.clone()), + MatchingRule::Composite(vec![kate, notes]), + ]; + + let matched = should_act( + "notes.txt — Kate", + "kate", + &["org.kde.kate"], + "/usr/bin/kate", + &rules, + &HashMap::new(), + ); + + assert_eq!(matched, Some(rules[2].clone())); + assert_eq!( + should_act("x", "x", &["x"], "x", &rules[..1], &HashMap::new()), + None + ); + } } diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index 7275aee6..9ab5f355 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -62,7 +62,7 @@ pub struct WindowManager { /// [`WindowManager::new`] was given another path pub command_listener: UnixListener, /// Whether `TogglePause` has paused the window manager: while it is paused, it handles no - /// event, and of the messages only `TogglePause`, `State` and `Stop` + /// event, and of the messages only `TogglePause`, `State`, `GlobalState` and `Stop` pub is_paused: bool, /// How many pixels `ResizeWindowEdge` and `ResizeWindowAxis` resize by pub resize_delta: i32, diff --git a/komorebi/src/window_manager_event.rs b/komorebi/src/window_manager_event.rs index d815bd80..314462a5 100644 --- a/komorebi/src/window_manager_event.rs +++ b/komorebi/src/window_manager_event.rs @@ -36,6 +36,8 @@ pub enum KWinNotification { WindowMinimized, /// `window.minimizedChanged` to false WindowRestored, + /// `window.captionChanged` + CaptionChanged, } /// Events komorebi raises itself, for example from a command @@ -56,6 +58,8 @@ pub enum WindowManagerEvent { Destroy(SystemNotification, Window), Minimize(SystemNotification, Window), Restore(SystemNotification, Window), + /// The window's title changed + TitleUpdate(SystemNotification, Window), /// The user asked komorebi to manage the window (`ManageFocusedWindow`) Manage(Window), /// The user asked komorebi to stop managing the window (`UnmanageFocusedWindow`) @@ -71,6 +75,7 @@ impl WindowManagerEvent { | WindowManagerEvent::Destroy(_, window) | WindowManagerEvent::Minimize(_, window) | WindowManagerEvent::Restore(_, window) + | WindowManagerEvent::TitleUpdate(_, window) | WindowManagerEvent::Manage(window) | WindowManagerEvent::Unmanage(window) => *window, }) @@ -91,7 +96,8 @@ impl WindowManagerEvent { | WindowManagerEvent::Show(n, _) | WindowManagerEvent::Destroy(n, _) | WindowManagerEvent::Minimize(n, _) - | WindowManagerEvent::Restore(n, _) => match n { + | WindowManagerEvent::Restore(n, _) + | WindowManagerEvent::TitleUpdate(n, _) => match n { SystemNotification::KWin(k) => k.to_string(), SystemNotification::Manual(m) => m.to_string(), }, diff --git a/komorebi/tests/rule_commands.rs b/komorebi/tests/rule_commands.rs new file mode 100644 index 00000000..ae730253 --- /dev/null +++ b/komorebi/tests/rule_commands.rs @@ -0,0 +1,238 @@ +//! The messages that add and show rules, and the title update that lets a rule decide once a +//! window has its title, against an in-memory desktop: the messages go in through +//! [`WindowManager::process_command`], and what comes out is which windows are tiled, which float +//! and what the daemon answers. +//! +//! The rule tables are process-wide, so each test names applications no other test of this file +//! names, and takes out the ignore rules it adds. +//! +//! Window ids are in the 29_000s. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::ApplicationIdentifier; +use komorebi::core::SocketMessage; +use komorebi::core::config_generation::MatchingRule; +use komorebi::window::Window; +use komorebi::window_manager::WindowManager; +use komorebi::window_manager_event::KWinNotification; +use komorebi::window_manager_event::SystemNotification; +use komorebi::window_manager_event::WindowManagerEvent; +use komorebi::window_manager_event_listener; +use std::sync::Arc; +use std::time::Duration; + +fn added(id: u32) -> WindowManagerEvent { + WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + Window::from(id), + ) +} + +fn activated(id: u32) -> WindowManagerEvent { + WindowManagerEvent::FocusChange( + SystemNotification::KWin(KWinNotification::WindowActivated), + Window::from(id), + ) +} + +fn title_changed(id: u32) -> WindowManagerEvent { + WindowManagerEvent::TitleUpdate( + SystemNotification::KWin(KWinNotification::CaptionChanged), + Window::from(id), + ) +} + +/// A window manager over a fake desktop with one 1920x1080 monitor and no windows +fn desktop() -> (Arc, WindowManager) { + let fake = Arc::new(FakeBackend::with_one_monitor()); + let (_sender, receiver) = crossbeam_channel::bounded(1); + // unix socket paths are limited to ~104 bytes on macOS, which the temp dir alone can exceed + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let socket = std::path::PathBuf::from(format!( + "/tmp/kmr-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + )); + + let wm = with_backend(fake.clone(), || { + let mut wm = WindowManager::new(receiver, Some(socket)).unwrap(); + wm.init().unwrap(); + wm + }); + + (fake, wm) +} + +/// Opens a window of the application `exe`, whose class is `class`, titled `title` +fn open(fake: &Arc, wm: &mut WindowManager, id: u32, app: (&str, &str, &str)) { + let (exe, class, title) = app; + fake.open_window(id, exe, class, title); + with_backend(fake.clone(), || wm.process_event(added(id))).unwrap(); +} + +fn send(fake: &Arc, wm: &mut WindowManager, message: SocketMessage) { + with_backend(fake.clone(), || wm.process_command(message, Vec::new())).unwrap(); +} + +/// What the daemon answers `message` with +fn ask(fake: &Arc, wm: &mut WindowManager, message: SocketMessage) -> String { + let mut reply = Vec::new(); + with_backend(fake.clone(), || wm.process_command(message, &mut reply)).unwrap(); + String::from_utf8(reply).unwrap() +} + +/// The windows of the focused workspace's containers, in order +fn tiled(wm: &WindowManager) -> Vec { + wm.focused_workspace() + .unwrap() + .containers() + .iter() + .map(|container| container.focused_window().unwrap().id) + .collect() +} + +/// The floating windows of the focused workspace +fn floating(wm: &WindowManager) -> Vec { + wm.focused_workspace() + .unwrap() + .floating_windows() + .iter() + .map(|window| window.id) + .collect() +} + +fn forget_ignore_rule(id: &str) { + komorebi::IGNORE_IDENTIFIERS + .lock() + .retain(|rule| !matches!(rule, MatchingRule::Simple(rule) if rule.id == id)); +} + +const TERMINAL: (&str, &str, &str) = ("foot", "foot", "a terminal"); + +#[test] +fn an_ignore_rule_takes_the_windows_it_names_out_of_the_layout_and_keeps_new_ones_out() { + let (fake, mut wm) = desktop(); + let game = ("game-29", "org.example.game", "a game"); + open(&fake, &mut wm, 29_001, game); + open(&fake, &mut wm, 29_002, TERMINAL); + assert_eq!(tiled(&wm), [29_001, 29_002]); + + send( + &fake, + &mut wm, + SocketMessage::IgnoreRule(ApplicationIdentifier::Exe, String::from("game-29")), + ); + assert_eq!(tiled(&wm), [29_002]); + + open(&fake, &mut wm, 29_003, game); + forget_ignore_rule("game-29"); + assert_eq!(tiled(&wm), [29_002]); +} + +#[test] +fn a_manage_rule_tiles_a_window_an_ignore_rule_covers() { + let (fake, mut wm) = desktop(); + // plasmashell is ignored from the start + let shell = ("plasmashell", "org.kde.plasmashell", "Desktop @ DP-1"); + open(&fake, &mut wm, 29_011, shell); + assert!(tiled(&wm).is_empty()); + + send( + &fake, + &mut wm, + SocketMessage::ManageRule( + ApplicationIdentifier::Class, + String::from("org.kde.plasmashell"), + ), + ); + open(&fake, &mut wm, 29_012, shell); + + assert_eq!(tiled(&wm), [29_012]); +} + +#[test] +fn a_session_float_rule_floats_the_focused_window_and_the_ones_like_it_until_it_is_cleared() { + let (fake, mut wm) = desktop(); + let calculator = ("kcalc", "org.kde.kcalc", "KCalc"); + open(&fake, &mut wm, 29_021, calculator); + open(&fake, &mut wm, 29_022, TERMINAL); + // the user gives the calculator the focus + fake.set_foreground(Some(29_021)); + with_backend(fake.clone(), || wm.process_event(activated(29_021))).unwrap(); + + send(&fake, &mut wm, SocketMessage::SessionFloatRule); + assert_eq!((tiled(&wm), floating(&wm)), (vec![29_022], vec![29_021])); + let rules = ask(&fake, &mut wm, SocketMessage::SessionFloatRules); + assert!(rules.contains("\"org.kde.kcalc\""), "{rules}"); + + open(&fake, &mut wm, 29_023, calculator); + assert_eq!(floating(&wm), [29_021, 29_023], "the next one floats too"); + + send(&fake, &mut wm, SocketMessage::ClearSessionFloatRules); + assert_eq!(ask(&fake, &mut wm, SocketMessage::SessionFloatRules), "[]"); + open(&fake, &mut wm, 29_024, calculator); + assert_eq!(tiled(&wm), [29_022, 29_024]); +} + +#[test] +fn a_window_whose_title_no_longer_matches_an_ignore_rule_is_tiled_when_the_title_changes() { + let (fake, mut wm) = desktop(); + send( + &fake, + &mut wm, + SocketMessage::IgnoreRule(ApplicationIdentifier::Title, String::from("pending-29")), + ); + open( + &fake, + &mut wm, + 29_031, + ("browser-29", "org.example.browser", "pending-29"), + ); + assert!(tiled(&wm).is_empty()); + + fake.set_title(29_031, "a page"); + with_backend(fake.clone(), || wm.process_event(title_changed(29_031))).unwrap(); + forget_ignore_rule("pending-29"); + + // the title update announces the window again, on the channel the events thread reads + let events = window_manager_event_listener::event_rx(); + let shown = loop { + let event = events + .recv_timeout(Duration::from_secs(5)) + .expect("the window is announced again"); + if event.window_id() == Some(29_031) { + break event; + } + }; + assert!(matches!(shown, WindowManagerEvent::Show(..)), "{shown:?}"); + with_backend(fake.clone(), || wm.process_event(shown)).unwrap(); + assert_eq!(tiled(&wm), [29_031]); +} + +#[test] +fn global_state_has_the_rules_and_debug_window_says_why_a_window_is_not_managed() { + let (fake, mut wm) = desktop(); + open(&fake, &mut wm, 29_041, TERMINAL); + fake.open_window(29_042, "krunner", "org.kde.krunner", "KRunner"); + + let state: serde_json::Value = + serde_json::from_str(&ask(&fake, &mut wm, SocketMessage::GlobalState)).unwrap(); + let ignored = state["ignore_identifiers"].to_string(); + assert!(ignored.contains("\"org.kde.krunner\""), "{ignored}"); + assert!(state["manage_identifiers"].is_array()); + assert!(state["data_dir"].is_string()); + + let debug: serde_json::Value = + serde_json::from_str(&ask(&fake, &mut wm, SocketMessage::DebugWindow(29_042))).unwrap(); + assert_eq!(debug["should_manage"], false); + assert_eq!(debug["class"], "org.kde.krunner"); + assert_eq!(debug["matches_ignore_identifier"]["id"], "org.kde.krunner"); + + let debug: serde_json::Value = + serde_json::from_str(&ask(&fake, &mut wm, SocketMessage::DebugWindow(29_041))).unwrap(); + assert_eq!(debug["should_manage"], true); + + // a window there is not is not answered + assert_eq!(ask(&fake, &mut wm, SocketMessage::DebugWindow(29_049)), ""); +} diff --git a/komorebi/tests/rules.rs b/komorebi/tests/rules.rs new file mode 100644 index 00000000..b95c4388 --- /dev/null +++ b/komorebi/tests/rules.rs @@ -0,0 +1,74 @@ +//! The rules that decide whether a window is managed, against an in-memory desktop. +//! +//! Window ids are in the 10_000s; the rule tables are process-wide, so a test removes the rule it +//! adds. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::ApplicationIdentifier; +use komorebi::core::config_generation::IdWithIdentifier; +use komorebi::core::config_generation::MatchingRule; +use komorebi::core::config_generation::MatchingStrategy; +use komorebi::window::Window; +use komorebi::window_manager::WindowManager; +use komorebi::window_manager_event::KWinNotification; +use komorebi::window_manager_event::SystemNotification; +use komorebi::window_manager_event::WindowManagerEvent; +use std::sync::Arc; + +fn added(id: u32) -> WindowManagerEvent { + WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + Window::from(id), + ) +} + +/// A window manager over a fake desktop with one 1920x1080 monitor (1920x1050 without the panel) +fn desktop() -> (Arc, WindowManager) { + desktop_on(FakeBackend::with_one_monitor()) +} + +fn desktop_on(fake: FakeBackend) -> (Arc, WindowManager) { + let fake = Arc::new(fake); + let (_sender, receiver) = crossbeam_channel::bounded(1); + // unix socket paths are limited to ~104 bytes on macOS, which the temp dir alone can exceed + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let socket = std::path::PathBuf::from(format!( + "/tmp/kmb-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + )); + + let wm = with_backend(fake.clone(), || { + let mut wm = WindowManager::new(receiver, Some(socket)).unwrap(); + wm.init().unwrap(); + wm + }); + + (fake, wm) +} + +#[test] +fn an_ignore_rule_keeps_a_window_unmanaged() { + let (fake, mut wm) = desktop(); + fake.open_window( + 10_051, + "secret-app", + "org.example.secret", + "hidden from komorebi", + ); + + let rule = MatchingRule::Simple(IdWithIdentifier { + kind: ApplicationIdentifier::Class, + id: "org.example.secret".to_string(), + matching_strategy: Some(MatchingStrategy::Equals), + }); + komorebi::IGNORE_IDENTIFIERS.lock().push(rule.clone()); + + let result = with_backend(fake.clone(), || wm.process_event(added(10_051))); + + komorebi::IGNORE_IDENTIFIERS.lock().retain(|r| r != &rule); + result.unwrap(); + + assert!(!wm.focused_workspace().unwrap().contains_window(10_051)); +} diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index 3b9b64ea..b4b733b9 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -31,6 +31,11 @@ //! `quick-save-resize`, `quick-load-resize`, `save-resize` and `load-resize` save the sizes of //! the tiles of the focused workspace and load them back. //! +//! `ignore-rule` and `manage-rule` add a rule that keeps an application out of the layout or +//! always in it, `session-float-rule` floats the focused window and every window like it until the +//! daemon stops, `session-float-rules` shows those rules and `clear-session-float-rules` removes +//! them, and `global-state` shows the rules the daemon has. +//! //! `subscribe-socket` and `unsubscribe-socket` add and remove a subscriber: a program that listens //! on a socket in the daemon's data directory, to which the daemon sends a notification after each //! event and each message it handles. @@ -46,6 +51,7 @@ use clap::Parser; use clap::ValueEnum; use color_eyre::eyre; use fs_tail::TailedFile; +use komorebi_client::ApplicationIdentifier; use komorebi_client::Axis; use komorebi_client::CycleDirection; use komorebi_client::DefaultLayout; @@ -174,6 +180,31 @@ gen_padding_adjustment_subcommand_args! { AdjustWorkspacePadding, } +macro_rules! gen_application_target_subcommand_args { + // SubCommand Pattern + ( $( $name:ident ),+ $(,)? ) => { + $( + #[derive(clap::Parser)] + pub struct $name { + #[clap(value_enum)] + identifier: ApplicationIdentifier, + /// Identifier as a string + id: String, + } + )+ + }; +} + +gen_application_target_subcommand_args! { + IgnoreRule, + ManageRule, + // IdentifyTrayApplication, + // IdentifyLayeredApplication, + // IdentifyObjectNameChangeApplication, + // IdentifyBorderOverflowApplication, + // RemoveTitleBar, +} + #[derive(Parser)] struct Resize { #[clap(value_enum)] @@ -327,6 +358,8 @@ enum SubCommand { DataDirectory, /// Show a JSON representation of the current window manager state State, + /// Show a JSON representation of the current global state + GlobalState, /// Show information about connected monitors #[clap(alias = "monitor-info")] MonitorInformation, @@ -471,6 +504,19 @@ enum SubCommand { /// Set the operation behavior when the focused window is not managed #[clap(arg_required_else_help = true)] UnmanagedWindowOperationBehaviour(UnmanagedWindowOperationBehaviour), + /// Add a rule to float the foreground window for the rest of this session + SessionFloatRule, + /// Show all session float rules + SessionFloatRules, + /// Clear all session float rules + ClearSessionFloatRules, + /// Add a rule to ignore the specified application + #[clap(arg_required_else_help = true)] + #[clap(alias = "float-rule")] + IgnoreRule(IgnoreRule), + /// Add a rule to always manage the specified application + #[clap(arg_required_else_help = true)] + ManageRule(ManageRule), /// Enable or disable mouse follows focus on all workspaces #[clap(arg_required_else_help = true)] MouseFollowsFocus(MouseFollowsFocus), @@ -671,12 +717,30 @@ fn main() -> eyre::Result<()> { SubCommand::State => { print_query(&SocketMessage::State); } + SubCommand::GlobalState => { + print_query(&SocketMessage::GlobalState); + } SubCommand::Query(args) => { print_query(&SocketMessage::Query(args.state_query)); } SubCommand::MonitorInformation => { print_query(&SocketMessage::MonitorInformation); } + SubCommand::SessionFloatRule => { + send_message(&SocketMessage::SessionFloatRule)?; + } + SubCommand::SessionFloatRules => { + print_query(&SocketMessage::SessionFloatRules); + } + SubCommand::ClearSessionFloatRules => { + send_message(&SocketMessage::ClearSessionFloatRules)?; + } + SubCommand::IgnoreRule(args) => { + send_message(&SocketMessage::IgnoreRule(args.identifier, args.id))?; + } + SubCommand::ManageRule(args) => { + send_message(&SocketMessage::ManageRule(args.identifier, args.id))?; + } SubCommand::ResizeDelta(args) => { send_message(&SocketMessage::ResizeDelta(args.pixels))?; } diff --git a/komorebic/tests/cli.rs b/komorebic/tests/cli.rs index 9d8c8c79..bcd145a6 100644 --- a/komorebic/tests/cli.rs +++ b/komorebic/tests/cli.rs @@ -353,6 +353,44 @@ fn the_offset_subcommands_send_the_offset_they_are_given() { ); } +#[test] +fn the_rule_subcommands_send_the_application_they_are_given() { + sends( + &["ignore-rule", "exe", "steam"], + r#"{"type":"IgnoreRule","content":["Exe","steam"]}"#, + ); + sends( + &["float-rule", "class", "org.kde.kcalc"], + r#"{"type":"IgnoreRule","content":["Class","org.kde.kcalc"]}"#, + ); + sends( + &["manage-rule", "title", "Picture-in-Picture"], + r#"{"type":"ManageRule","content":["Title","Picture-in-Picture"]}"#, + ); + sends(&["session-float-rule"], r#"{"type":"SessionFloatRule"}"#); + sends( + &["clear-session-float-rules"], + r#"{"type":"ClearSessionFloatRules"}"#, + ); +} + +#[test] +fn global_state_and_session_float_rules_print_the_answer_of_the_daemon() { + for (args, message) in [ + ("global-state", r#"{"type":"GlobalState"}"#), + ("session-float-rules", r#"{"type":"SessionFloatRules"}"#), + ] { + let runtime = Runtime::new(); + let daemon = runtime.serve("[]"); + + let output = runtime.komorebic(&[args]); + + assert!(output.status.success(), "{output:?}"); + assert_eq!(daemon.join().unwrap(), message); + assert_eq!(stdout(&output), "[]\n"); + } +} + #[test] fn the_subcommands_about_the_focused_window_and_pausing_send_their_message() { sends(&["minimize"], r#"{"type":"Minimize"}"#); @@ -429,6 +467,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "adjust-workspace-padding.md", "cancel-preselect.md", "change-layout.md", + "clear-session-float-rules.md", "close.md", "completions.md", "container-padding.md", @@ -440,10 +479,13 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "focus.md", "focused-workspace-container-padding.md", "focused-workspace-padding.md", + "global-state.md", "global-work-area-offset.md", + "ignore-rule.md", "layout-ratios.md", "load-resize.md", "log.md", + "manage-rule.md", "manage.md", "minimize.md", "monitor-information.md", @@ -464,6 +506,8 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "retile.md", "save-resize.md", "scrolling-layout-columns.md", + "session-float-rule.md", + "session-float-rules.md", "start.md", "state.md", "stop.md", diff --git a/nix/tests/vm/suites/commands/test.py b/nix/tests/vm/suites/commands/test.py index e2d6b5f3..b5f06ba5 100644 --- a/nix/tests/vm/suites/commands/test.py +++ b/nix/tests/vm/suites/commands/test.py @@ -219,6 +219,12 @@ mouse-follows-focus enable mouse-follows-focus disable toggle-mouse-follows-focus toggle-mouse-follows-focus +session-float-rule +session-float-rules +clear-session-float-rules +toggle-float +ignore-rule exe nonexistent +manage-rule exe nonexistent manage close retile @@ -269,8 +275,50 @@ checks.check( kde.wait_for(lambda: kde.managed() == before and healthy() == 0, timeout=10), ) +print("== 3c. a rule that matches a title decides when the window has its title") +kde.komorebic("ignore-rule", "title", "pending-rule") +before_all = kde.managed_all() + + +def ignored(title: str) -> bool: + """True once the daemon has logged that it leaves the window titled `title` alone.""" + return any( + "should not be managed" in line and f"title: {title}," in line + for line in kde.komorebi_log().splitlines() + ) + + +# foot is titled pending-rule when it opens, and gives itself another title after 4 seconds +rename = 'sleep 4; printf "\\033]0;renamed-rule\\007"; sleep 3000' +kde.spawn(f"foot -T pending-rule sh -c {shlex.quote(rename)}") +checks.check( + "a window the ignore rule matches by its title is left alone", + kde.wait_for(lambda: ignored("pending-rule"), timeout=20) and kde.managed_all() == before_all, +) +checks.check( + "once it has a title the rule does not match, it is tiled", + kde.wait_for( + lambda: kde.wid("renamed-rule") != "" and kde.managed_all() == before_all + 1, + timeout=20, + ) + and healthy() == 0, +) +# the daemon's log names a window by the title it had when it was focused, which for this one is +# the first: close it, so that the last check finds every window it looks for in the log +kde.close(kde.wid("renamed-rule")) +checks.check( + "closed, it leaves komorebi", + kde.wait_for(lambda: kde.wid("renamed-rule") == "" and healthy() == 0, timeout=10), +) + print("== 4. what komorebic only prints") -for args in ["state", "monitor-information", "data-directory"]: +for args in [ + "state", + "global-state", + "monitor-information", + "data-directory", + "session-float-rules", +]: checks.check(f"komorebic {args}", kde.run(f"komorebic {args}")[0] == 0) for query in [ "focused-monitor-index", diff --git a/nix/tests/vm/suites/kwin-events/test.py b/nix/tests/vm/suites/kwin-events/test.py index c6e082bc..40c4e3c7 100644 --- a/nix/tests/vm/suites/kwin-events/test.py +++ b/nix/tests/vm/suites/kwin-events/test.py @@ -1,8 +1,10 @@ # The daemon reads a real KWin through its script. What only a real KWin can tell: that the script # loads and reports the desktop, that KWin's signals reach the window manager as the events # `komorebi/src/kwin/state.rs` and `komorebi/tests/wm_events.rs` feed it by hand, in the order KWin -# raised them (KWin activates a new window before it announces it), and that it takes its script -# out of KWin when it stops. +# raised them (KWin activates a new window before it announces it), that a window giving itself +# another title is reported, and that it takes its script out of KWin when it stops. +import shlex + from komorebi_vm import Checks, Kde, daemon from komorebi_vm.daemon import Event @@ -97,6 +99,23 @@ checks.check( in_order == ["Show", "Minimize", "Restore", "Destroy"], ) +print("== a window gives itself another title") +rename = 'sleep 4; printf "\\033]0;beta-renamed\\007"; sleep 3000' +kde.spawn(f"foot -T beta sh -c {shlex.quote(rename)}") +checks.check( + "KWin's focus on it reached the daemon, by title", + kde.wait_for(lambda: "beta" in daemon.window_ids(daemon_log()), timeout=30), +) +b = daemon.window_ids(daemon_log()).get("beta", -1) +checks.check( + "KWin has it under its new title", + kde.wait_for(lambda: kde.wid("beta-renamed") != "", timeout=20), +) +checks.check( + "the title change is a TitleUpdate", + kde.wait_for(lambda: handled(Event("TitleUpdate", b, "CaptionChanged")), timeout=10), +) + print("== stop") kde.stop_komorebi() checks.check("the daemon exited", not kde.komorebi_running())