mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-15 13:22:19 +01:00
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.
This commit is contained in:
1 parent
c95be9391e
commit
1b10611022
32 files changed
+1755
-57
No files matched your search
Generated
+13
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
```text
|
||||
Clear all session float rules
|
||||
|
||||
Usage: clear-session-float-rules
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
Print help
|
||||
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
```text
|
||||
Show a JSON representation of the current global state
|
||||
|
||||
Usage: global-state
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
Print help
|
||||
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
```text
|
||||
Add a rule to ignore the specified application
|
||||
|
||||
Usage: ignore-rule <IDENTIFIER> <ID>
|
||||
|
||||
Arguments:
|
||||
<IDENTIFIER>
|
||||
Possible values:
|
||||
- exe: Executable name
|
||||
- class: Role or subrole
|
||||
- title: Window title
|
||||
- path: Executable path
|
||||
|
||||
<ID>
|
||||
Identifier as a string
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
```text
|
||||
Add a rule to always manage the specified application
|
||||
|
||||
Usage: manage-rule <IDENTIFIER> <ID>
|
||||
|
||||
Arguments:
|
||||
<IDENTIFIER>
|
||||
Possible values:
|
||||
- exe: Executable name
|
||||
- class: Role or subrole
|
||||
- title: Window title
|
||||
- path: Executable path
|
||||
|
||||
<ID>
|
||||
Identifier as a string
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
```
|
||||
@@ -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
|
||||
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
```text
|
||||
Show all session float rules
|
||||
|
||||
Usage: session-float-rules
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
Print help
|
||||
|
||||
```
|
||||
+38
-10
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Vendored
+1
@@ -85,6 +85,7 @@ interface KWinWindow {
|
||||
readonly desktops: VirtualDesktop[];
|
||||
readonly frameGeometryChanged: Signal<[oldGeometry: QRectF]>;
|
||||
readonly minimizedChanged: Signal;
|
||||
readonly captionChanged: Signal;
|
||||
closeWindow(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Output> {
|
||||
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![]);
|
||||
|
||||
@@ -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<Mutex<AspectRatio>> = 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<Mutex<HashMap<String, Regex>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
/// Windows that are tiled even when an ignore rule covers them (`ManageRule`)
|
||||
static ref MANAGE_IDENTIFIERS: Arc<Mutex<Vec<MatchingRule>>> = 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<Mutex<Vec<MatchingRule>>> = 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<Mutex<Vec<MatchingRule>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
/// Windows that are managed as floating windows from the moment they open
|
||||
static ref FLOATING_APPLICATIONS: Arc<Mutex<Vec<MatchingRule>>> = Arc::new(Mutex::new(vec![]));
|
||||
/// Window classes that are never managed, whatever the rules say
|
||||
static ref PERMAIGNORE_CLASSES: Arc<Mutex<Vec<String>>> = 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<Mutex<Vec<u32>>> = Arc::new(Mutex::new(vec![]));
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
#[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<MatchingRule>,
|
||||
pub manage_identifiers: Vec<MatchingRule>,
|
||||
// pub layered_whitelist: Vec<MatchingRule>,
|
||||
// pub tray_and_multi_window_identifiers: Vec<MatchingRule>,
|
||||
// pub name_change_on_launch_identifiers: Vec<MatchingRule>,
|
||||
// pub monitor_index_preferences: HashMap<usize, Rect>,
|
||||
// pub ignored_duplicate_monitor_serial_ids: Vec<String>,
|
||||
// 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
|
||||
|
||||
+585
-12
@@ -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<WindowManagerEvent>,
|
||||
debug: &mut RuleDebug,
|
||||
) -> eyre::Result<bool> {
|
||||
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<WindowStyle>,
|
||||
// pub extended_window_style: Option<ExtendedWindowStyle>,
|
||||
pub title: Option<String>,
|
||||
pub exe_name: Option<String>,
|
||||
pub class: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub matches_permaignore_class: Option<String>,
|
||||
pub matches_ignore_identifier: Option<MatchingRule>,
|
||||
pub matches_managed_override: Option<MatchingRule>,
|
||||
// pub matches_layered_whitelist: Option<MatchingRule>,
|
||||
pub matches_floating_applications: Option<MatchingRule>,
|
||||
// pub matches_wsl2_gui: Option<String>,
|
||||
// pub matches_no_titlebar: Option<MatchingRule>,
|
||||
}
|
||||
|
||||
/// 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<WindowManagerEvent>,
|
||||
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<String, Regex>,
|
||||
) -> Option<MatchingRule> {
|
||||
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<String, Regex>,
|
||||
) -> 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<MatchingStrategy>,
|
||||
) -> 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<String, Regex>) -> 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<MatchingStrategy>) -> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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<FakeBackend>, 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<FakeBackend>, 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<FakeBackend>, 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<FakeBackend>, 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<u32> {
|
||||
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<u32> {
|
||||
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)), "");
|
||||
}
|
||||
@@ -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<FakeBackend>, WindowManager) {
|
||||
desktop_on(FakeBackend::with_one_monitor())
|
||||
}
|
||||
|
||||
fn desktop_on(fake: FakeBackend) -> (Arc<FakeBackend>, 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));
|
||||
}
|
||||
@@ -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))?;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user