feat(wm): fullscreen is left alone

A fullscreen window covers its monitor on purpose. Moving it would take it out
of fullscreen, or be ignored, so Window::set_position does nothing for a
fullscreen window, and every move the window manager makes goes through it.

The bridge reports KWin's fullScreenChanged as a fullscreen event, and the table
keeps what it says, which is what the window manager asks. The window stays in
its container, and the others are tiled as if it were not fullscreen. When it
comes back from fullscreen, KWin gives it the frame it had before, which may not
be its tile any more, so the workspace is tiled again.

Section E of the features suite checks it in a real Plasma session: a fullscreen
window covers the monitor, a window that opens does not take it out of
fullscreen, and it is in a tile that overlaps no other when it comes back.
This commit is contained in:
LGUG2Z committed 2026-10-28 07:20:57 -07:00
1 parent d6ad3b4292
commit e563988258
21 files changed
+586 -38

No files matched your search

+2 -1
View File
@@ -222,7 +222,8 @@ another layout, `layout-ratios` and `scrolling-layout-columns` tune it,
focused window to the largest tile, `toggle-float` takes it out of the
layout, `toggle-monocle` gives its container the whole work area, hiding the
others, and puts it back, `toggle-maximize` has KWin maximize the focused window
and restore it (a window the user maximizes leaves the layout the same way),
and restore it (a window the user maximizes leaves the layout the same way, and
a fullscreen window is left where it is),
`toggle-workspace-layer` switches the focus commands between the tiles and the
floating windows, and `toggle-lock` keeps its container at its
place. The scrolling layout is a strip of columns that runs past the edges of
+23 -8
View File
@@ -50,10 +50,10 @@ that is added has its place here.
komorebi asked for it or the user did. It is in no container, so it has left the layout, until
it is put back in a container of its own where it was ([`Workspace::maximized_window`]).
- **Event**: what the compositor reports about a window: it was shown, destroyed, given
the focus, minimized or restored, maximized or restored from it, dragged by the user, put on
another workspace's desktop, or moved to another output; and that a monitor shows another
workspace ([`WindowManagerEvent`]). Two events come from komorebi itself: a command asks it to
manage the focused window, or to stop managing it.
the focus, minimized or restored, fullscreen or back from it, maximized or restored from it,
dragged by the user, put on another workspace's desktop, or moved to another output; and that a
monitor shows another workspace ([`WindowManagerEvent`]). Two events come from komorebi 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, show a workspace, retile, stop) or a
question (the state, the monitors, one [`StateQuery`]).
@@ -113,10 +113,10 @@ the same logic runs against both. The reasons are in
The desktop, as the window manager sees it, is two lists and a pointer:
- **Windows**, each a [`WindowInfo`]: its id, process, title, `app_id`, executable, frame,
whether it is minimized, whether it is maximized, whether it is a normal application window
(as opposed to a dialog, a splash screen or a dock), and whether it is alive. A window that
has been destroyed stays in the table for a short time with `alive` false, so that the event
that mentions it can still be logged.
whether it is minimized, whether it is fullscreen, whether it is maximized, whether it is a
normal application window (as opposed to a dialog, a splash screen or a dock), and whether it is
alive. A window that has been destroyed stays in the table for a short time with `alive` false,
so that the event that mentions it can still be logged.
- **Monitors**, each a [`MonitorInfo`]: its id, name, an identity that survives a
hot-plug, its bounds and its work area.
- **The pointer**, a position if the compositor says where it is, and the **focus**, the id
@@ -638,6 +638,14 @@ reported too, and find the workspace as they leave it, so they change nothing. A
already maximized when komorebi starts ([`load_workspace_information`]) or when it opens (a
`Show`) is the maximized window the same way.
A window that goes fullscreen needs no message and no model of its own. The compositor covers the
monitor with it, the bridge reports a `fullscreen` event, and the table keeps
[`WindowInfo::fullscreen`] for the window. The window stays in its container, and the layout keeps
its tile, but [`Window::set_position`], which every move the window manager makes goes through,
does nothing for a fullscreen window, so a retile, a centering or a window that opens moves it
nowhere. A `FullscreenChange` back from fullscreen tiles the workspace again, because KWin gives
the window the frame it had before, which may not be its tile any more.
A workspace is on one of two layers ([`Workspace::layer`]), and the focus, move and resize
messages go to the windows of that layer. `ToggleFloat` puts the workspace on the floating layer,
and putting the window back, or a `FocusChange` to a window that is not floating, a new tiled
@@ -1281,6 +1289,10 @@ the fake. The window manager is its only caller, through [`backend`]. The contra
| A change to a window komorebi does not have is ignored | framework path: [`WindowManager::adopt_maximize_change`] looks the window up in the index of managed windows first | `a_change_for_a_window_komorebi_has_not_taken_in_is_ignored` | none |
| The bridge reports a window as maximized only when KWin has it maximized both ways | test: `isMaximized` in [`bridge.js`] reads KWin's `maximizeMode`, which is 3 for both ways | `bridge.test.js` ("a window maximized one way only is not maximized", "a window the user maximizes or restores is reported, and one maximized one way is not") | none |
| In a real KWin, a window the user maximizes is komorebi's maximized window, the layout does nothing meanwhile, and the window goes back to its tile | VM test | `vm-user-maximize` | none |
| A fullscreen window is never moved | framework path: [`Window::set_position`] returns before the backend when [`WindowInfo::fullscreen`] is set, and every tile, centering and float of the window manager goes through it | `a_fullscreen_window_is_left_covering_the_monitor_and_goes_back_to_its_tile_afterwards`, `a_fullscreen_window_is_not_moved_and_is_again_when_it_is_not_fullscreen` | none |
| A window that leaves fullscreen is put in the tile the layout has for it | framework path: `handle_event` tiles the workspace in front again on a `FullscreenChange` back from fullscreen | `a_fullscreen_window_is_left_covering_the_monitor_and_goes_back_to_its_tile_afterwards` | none |
| The bridge reports a fullscreen change with what the window is now | test: the bridge reads `fullScreen` when KWin raises `fullScreenChanged`, and the table keeps it | `bridge.test.js` ("a window that goes fullscreen or comes back from it is reported as it is now", and the session), `going_fullscreen_and_coming_back_are_events_and_are_remembered` | none |
| In a real KWin, a fullscreen window covers the monitor, a window that opens does not take it out of fullscreen, and it goes back to a tile that overlaps no other | VM test | `vm-features` (section E) | none |
| A window stays maximized through a retile | framework path: [`Workspace::update`] asks the backend to maximize it again, and moves no container | `a_retile_while_a_window_is_maximized_asks_the_compositor_to_maximize_it_again` | none |
| A maximized window that closes takes the maximize with it | framework path: [`Workspace::remove_window`] looks at the maximized window first, which the index of managed windows lists | `a_maximized_window_that_closes_leaves_the_layout_whole` | none |
| In a real KWin, the maximized window fills the work area, is on top, and goes back to its tile | VM test | `vm-features` ("the focused window fills the work area", "and is the one on top", "toggled back, it is in its tile again") | none |
@@ -1574,6 +1586,8 @@ A window is shown, and ends up on a tile:
[`komorebi/tests/user_maximize.rs`](../../komorebi/tests/user_maximize.rs) and
[`komorebi/tests/user_maximize_cases.rs`](../../komorebi/tests/user_maximize_cases.rs), a window
the user maximizes;
[`komorebi/tests/fullscreen.rs`](../../komorebi/tests/fullscreen.rs), a window that goes
fullscreen and comes back;
[`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
@@ -1772,6 +1786,7 @@ A window is shown, and ends up on a tile:
[`Workspace::adopt_maximized_window`]: ../../komorebi/src/workspace.rs
[`WindowManager::adopt_maximize_change`]: ../../komorebi/src/window_manager.rs
[`WindowInfo::maximized`]: ../../komorebi/src/backend/mod.rs
[`WindowInfo::fullscreen`]: ../../komorebi/src/backend/mod.rs
[`load_workspace_information`]: ../../komorebi/src/desktop.rs
[`WindowManager::adjust_workspace_padding`]: ../../komorebi/src/window_manager.rs
[`Workspace::apply_window_based_work_area_offset`]: ../../komorebi/src/workspace.rs
+13
View File
@@ -658,3 +658,16 @@ the layout as `toggle-maximize` makes a window leave it, and what is asked of th
nothing "until it is restored, by `toggle-maximize` or by the user" (the comment in
`process_command`). Because the report is the same either way, one comparison serves both: a window
that is, or is not, already the workspace's maximized window changes nothing.
## Why does komorebi leave a fullscreen window alone, and tile the workspace when it comes back?
Because the window covers the monitor on purpose. The comment in
[`Window::set_position`](../../komorebi/src/window.rs) says that "moving it would take it out of
fullscreen, or be ignored", and that "it goes back to its tile when it leaves fullscreen". The
window stays in its container, so the other windows are tiled as if it were not fullscreen, and
every move the window manager makes goes through `set_position`, which is where the window is
skipped. When the window comes back, "KWin gives it the frame it had before, which may not be the
tile it has now" (the comment in `handle_event`), so the workspace is tiled again. The bridge's
second request for a position gives up after three tries for the same reason: so that a window that
cannot go where it was asked, "a fullscreen one, say", is left alone rather than chased (the comment
on `settle` in [`bridge.js`](../../komorebi/src/kwin/bridge.js)).
+13 -6
View File
@@ -123,12 +123,13 @@ It connects to the session bus as `org.komorebi.Bridge`, writes the bridge to
`$XDG_RUNTIME_DIR/komorebi/bridge.js`, and asks KWin to load it under the name
`komorebi-bridge`, replacing a script of that name left by an earlier run. The script reports
the windows and outputs it finds, and from then on every window that opens, closes, takes the
focus, is minimized or brought back, is maximized or restored, moves or changes size, the start
and the end of every drag of a window by the user, and every change of the outputs (one that
changes resolution or scale, and the room a panel leaves). KWin has no signal for that room, so
the script looks at the outputs again when a dock window opens, closes or moves, and once a
second; it also compares KWin's active window with the one it last reported once a second. The
daemon waits up to 10 seconds for the first report and does not start without it.
focus, is minimized or brought back, goes fullscreen or comes back, is maximized or restored,
moves or changes size, the start and the end of every drag of a window by the user, and every
change of the outputs (one that changes resolution or scale, and the room a panel leaves). KWin
has no signal for that room, so the script looks at the outputs again when a dock window opens,
closes or moves, and once a second; it also compares KWin's active window with the one it last
reported once a second. The daemon waits up to 10 seconds for the first report and does not
start without it.
It also watches for the machine waking up and the screen being unlocked, with two signals that
are not KWin's ([D-Bus](#d-bus)): logind's `PrepareForSleep` on the system bus, and the lock
@@ -173,6 +174,12 @@ shortcut) alike, and the bridge reports a window as maximized only when it is ma
ways (`maximizeMode` 3): a window maximized in one direction is a restored window to komorebi.
The VM test `vm-user-maximize` maximizes windows with `kdotool windowstate`, in both directions,
and passes against KWin 6.8.1 at time of writing (2026-10-27).
A fullscreen window is reported by `fullScreenChanged`, and komorebi asks KWin for no frame for
it while it is fullscreen. When it leaves fullscreen KWin gives it the frame it had before,
whatever komorebi has done to the layout since. The VM test `vm-features` takes a window
fullscreen with `kdotool windowstate`, and checks that it covers the output, that a window that
opens meanwhile leaves it there, and that it is in a tile when it comes back. It passes against
KWin 6.8.1 at time of writing (2026-10-27).
#### When KWin restarts
+2 -2
View File
@@ -121,8 +121,8 @@ the run.
- `Kde.session(machine, scale=)` attaches to the session node's own Plasma
session once its desktop is up, and `shot(name)` returns a screenshot as a
Pillow image, keeping `NAME.png` in the output directory.
- `activate`, `minimize`, `maximize`, `unmaximize` and `close` act on a window through
`kdotool`, as the user would (`maximize` does what the title bar's button does, and the
- `activate`, `minimize`, `maximize`, `unmaximize`, `fullscreen` and `close` act on a window
through `kdotool`, as the user would (`maximize` does what the title bar's button does, and the
daemon is not asked), and `wid_by_class` finds a window by its app id or X11 class.
`Kde.headless(..., xwayland=True)` gives the session XWayland, for X11
clients.
+1
View File
@@ -44,6 +44,7 @@ the floating ones,
[Monocle](usage/monocle.md) how to give one window the whole work area,
[Maximizing windows](usage/maximizing-windows.md) how to have KWin maximize one, and what
komorebi does when you maximize one yourself,
[Fullscreen windows](usage/fullscreen-windows.md) what komorebi does with one,
[Managing windows](usage/managing-windows.md) how to minimize, close and unmanage the focused
window and pause the daemon, and
[Padding and work area offsets](usage/padding-and-offsets.md) how to put gaps around the tiles
+17
View File
@@ -0,0 +1,17 @@
# Fullscreen windows
A window that goes fullscreen, with its program's own key (F11 in many programs), the window
menu or a shortcut, covers its whole monitor, the panels included. komorebi leaves it there. It
never moves or resizes a fullscreen window, whatever happens to the others: a retile, a window
that opens or closes, a [layout](layouts.md) that changes.
The window stays in its container, so the layout keeps a tile for it, and the other windows are
tiled as if it were not fullscreen. The fullscreen window covers them, and they are in their tiles
when it leaves.
When the window leaves fullscreen, KWin gives it the frame it had before. That frame may not be
its tile any more, because windows can have opened or closed meanwhile, so komorebi tiles the
workspace again and the window goes to the tile the layout has for it.
There is no `komorebic` command for fullscreen: it is KWin's, and the program's. A window that is
fullscreen when komorebi starts is left alone the same way.
+61
View File
@@ -97,6 +97,8 @@ struct State {
commands: Vec<Command>,
/// windows the compositor has maximized, with the frame to give back
maximized: HashMap<u32, Rect>,
/// windows that are fullscreen, with the frame they had before
fullscreen: HashMap<u32, Rect>,
/// how many virtual desktops there are
desktops: usize,
/// the desktop every window is on
@@ -282,6 +284,7 @@ impl FakeBackend {
bottom: 600,
},
minimized: false,
fullscreen: false,
maximized: false,
normal: true,
alive: true,
@@ -354,6 +357,35 @@ impl FakeBackend {
self.state.lock().set_maximized(id, maximize);
}
/// Puts a window in or out of fullscreen as a compositor does, without the window manager
/// being asked, so no command is recorded: the window covers its monitor, and when it leaves
/// fullscreen it gets the frame it had before, which may not be where the window manager would
/// put it now. A test sends the event that says so.
pub fn set_fullscreen(&self, id: u32, fullscreen: bool) {
let mut state = self.state.lock();
let covers = state
.monitor_of(id)
.and_then(|m| state.monitors.iter().find(|monitor| monitor.id == m))
.map(|monitor| monitor.size);
if fullscreen {
let Some(window) = state.windows.get_mut(&id) else {
return;
};
let before = window.rect;
window.fullscreen = true;
if let Some(covers) = covers {
window.rect = covers;
}
state.fullscreen.insert(id, before);
} else if let Some(before) = state.fullscreen.remove(&id)
&& let Some(window) = state.windows.get_mut(&id)
{
window.fullscreen = false;
window.rect = before;
}
}
/// Returns true if and only if the window is maximized, by a command or by
/// [`FakeBackend::user_maximize`], and has not been restored since.
pub fn is_maximized(&self, id: u32) -> bool {
@@ -489,6 +521,7 @@ fn permissive_window(id: u32) -> WindowInfo {
path: format!("/usr/bin/exe{id}"),
rect: Rect::default(),
minimized: false,
fullscreen: false,
maximized: false,
normal: true,
alive: true,
@@ -826,6 +859,34 @@ mod tests {
assert_eq!(fake.rect_of(1), Some(tile(100, 200)));
}
#[test]
fn a_fullscreen_window_covers_its_monitor_and_gets_its_frame_back() {
let fake = fake_with(&[1]);
fake.set_rect(1, &tile(100, 200)).unwrap();
fake.clear_commands();
fake.set_fullscreen(1, true);
assert!(fake.window(1).expect("window 1 was opened").fullscreen);
assert!(fake.windows()[0].fullscreen);
assert_eq!(
fake.rect_of(1),
Some(Rect {
left: 0,
top: 0,
right: 1920,
bottom: 1080,
}),
"the whole monitor, panel included"
);
assert!(fake.commands().is_empty());
fake.set_fullscreen(1, false);
assert!(!fake.window(1).expect("window 1 was opened").fullscreen);
assert_eq!(fake.rect_of(1), Some(tile(100, 200)));
}
#[test]
fn a_command_about_a_window_nobody_opened_is_an_error() {
let fake = fake_with(&[1]);
+3
View File
@@ -77,6 +77,9 @@ pub struct WindowInfo {
pub rect: Rect,
/// Whether the window is minimized.
pub minimized: bool,
/// Whether the window is fullscreen: it covers its monitor, panels included, and the window
/// manager leaves it where it is.
pub fullscreen: bool,
/// Whether the window is maximized, whether by the user (the title bar, a double click, a
/// shortcut) or because komorebi asked ([`Backend::maximize`]).
pub maximized: bool,
+19 -14
View File
@@ -51,17 +51,18 @@
/**
* 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; `maximized` says whether a window is
* maximized both ways now, whoever maximized it; `caption` is a window's new title; `output` says
* that a window is on another output, by the output's name (empty for none), with the frame the
* window has there; `screens` says which outputs there are, with their geometry and work area,
* `desktops` which desktops a window is on now, `desktop_list` which desktops there are, and
* `current_desktops` which desktop each output shows, each sent when it is not what was last sent;
* `move_resize_started` and `move_resize_finished` are the start and the end of a drag of a window
* by the user, with its frame, and at the end where the pointer is and the frame the user last
* asked for (`requested`, or `null` when the drag asked for none); `move_resize_step` is the
* frame the user asks for part way through a resize, at most one every 16 ms; `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; `fullscreen` says whether a window is
* fullscreen now; `maximized` says whether a window is maximized both ways now, whoever maximized
* it; `caption` is a window's new title; `output` says that a window is on another output, by the
* output's name (empty for none), with the frame the window has there; `screens` says which
* outputs there are, with their geometry and work area, `desktops` which desktops a window is on
* now, `desktop_list` which desktops there are, and `current_desktops` which desktop each output
* shows, each sent when it is not what was last sent; `move_resize_started` and
* `move_resize_finished` are the start and the end of a drag of a window by the user, with its
* frame, and at the end where the pointer is and the frame the user last asked for (`requested`,
* or `null` when the drag asked for none); `move_resize_step` is the frame the user asks for part
* way through a resize, at most one every 16 ms; `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[], desktops: Desktop[],
@@ -71,6 +72,7 @@
* @typedef {{ type: "removed", id: string }} Removed
* @typedef {{ type: "activated", id: string | null }} Activated
* @typedef {{ type: "minimized", id: string, v: boolean }} Minimized
* @typedef {{ type: "fullscreen", id: string, v: boolean }} Fullscreen
* @typedef {{ type: "maximized", id: string, v: boolean }} Maximized
* @typedef {{ type: "geometry", id: string, geo: Rect }} Geometry
* @typedef {{ type: "screens", screens: Screen[] }} Screens
@@ -88,9 +90,9 @@
* @typedef {{ type: "move_resize_step", id: string, geo: Rect }} MoveResizeStep
* @typedef {{ type: "cursor", token: number, x: number, y: number }} CursorAnswer
* @typedef {{ type: "failed", batch: number, cmds: Command[] }} Failed
* @typedef {Snapshot | Added | Removed | Activated | Minimized | Maximized | Geometry | Screens
* | OutputChange | Caption | Desktops | DesktopList | CurrentDesktops | MoveResizeStarted
* | MoveResizeFinished | MoveResizeStep | CursorAnswer | Failed} BridgeEvent
* @typedef {Snapshot | Added | Removed | Activated | Minimized | Fullscreen | Maximized | Geometry
* | Screens | OutputChange | Caption | Desktops | DesktopList | CurrentDesktops
* | MoveResizeStarted | MoveResizeFinished | MoveResizeStep | CursorAnswer | Failed} BridgeEvent
*/
/**
@@ -444,6 +446,9 @@ function track(w, announce) {
w.minimizedChanged.connect(function () {
emit("minimized", { id: id, v: !!w.minimized });
});
w.fullScreenChanged.connect(function () {
emit("fullscreen", { id: id, v: !!w.fullScreen });
});
// moved to another output by somebody else: the user's shortcut, or KWin when an output goes
// away (its geometry goes with it, as the two arrive in no fixed order)
w.outputChanged.connect(function () {
+41 -2
View File
@@ -61,8 +61,10 @@ function geometry(x, y, width, height) {
// `closeWindow` closes it once it is in a KWin. `setMaximize` sets `maximizeMode` the way KWin's
// does, raises `maximizedChanged` when the mode changes, and records what it was asked in
// `maximizations`: the user's title bar calls it too, so a test maximizes a window for the user by
// calling it. `place` is where KWin puts the window when it is asked for a frame: where it was
// asked, unless a test says otherwise.
// calling it. `fullScreen` is a plain property: nothing the bridge does changes it, so a test sets
// it and raises `fullScreenChanged` as KWin does when the user takes a window fullscreen. `place`
// is where KWin puts the window when it is asked for a frame: where it was asked, unless a test
// says otherwise.
function window({
id,
minimized = false,
@@ -101,6 +103,7 @@ function window({
output: null,
frameGeometryChanged: new Signal(),
minimizedChanged: new Signal(),
fullScreenChanged: new Signal(),
outputChanged: new Signal(),
maximizedChanged: new Signal(),
captionChanged: new Signal(),
@@ -411,6 +414,12 @@ test("what the bridge sends for a session is what the daemon's tests read", () =
editor.output = { name: "DP-2" };
editor.outputChanged.emit();
// the user takes the editor fullscreen and back
editor.fullScreen = true;
editor.fullScreenChanged.emit();
editor.fullScreen = false;
editor.fullScreenChanged.emit();
assert.deepStrictEqual(k.events(), events);
assert.deepStrictEqual(editor.frameGeometry, geometry(40, 30, 1040, 1050));
assert.equal(k.workspace.activeWindow, editor);
@@ -643,6 +652,36 @@ test("a window maximized one way only is not maximized", () => {
);
});
test("a window that goes fullscreen or comes back from it is reported as it is now", () => {
const w = window({ id: "{w}" });
const k = kwin({ windows: [w] });
w.fullScreen = true;
w.fullScreenChanged.emit();
w.fullScreen = false;
w.fullScreenChanged.emit();
assert.deepStrictEqual(
k.events().slice(1).map((e) => [e.type, e.id, e.v]),
[
["fullscreen", "{w}", true],
["fullscreen", "{w}", false],
],
);
});
test("a window that is fullscreen when the script starts is reported as fullscreen", () => {
const windows = [window({ id: "{a}" }), window({ id: "{b}", fullScreen: true })];
const k = kwin({ windows });
const [snapshot] = k.events();
assert.deepStrictEqual(
snapshot.windows.map((w) => w.fullscreen),
[false, true],
);
});
test("a window the user maximizes or restores is reported, and one maximized one way is not", () => {
const w = window({ id: "{w}" });
const k = kwin({ windows: [w] });
+3 -1
View File
@@ -126,5 +126,7 @@
{
"type": "output", "seq": 31, "id": "{0e5a1c3b-0000-4000-8000-00000000000a}",
"geo": {"x": 40, "y": 30, "w": 1040, "h": 1050}, "output": "DP-2"
}
},
{"type": "fullscreen", "seq": 32, "id": "{0e5a1c3b-0000-4000-8000-00000000000a}", "v": true},
{"type": "fullscreen", "seq": 33, "id": "{0e5a1c3b-0000-4000-8000-00000000000a}", "v": false}
]
+1
View File
@@ -96,6 +96,7 @@ interface KWinWindow {
desktops: VirtualDesktop[];
readonly frameGeometryChanged: Signal<[oldGeometry: QRectF]>;
readonly minimizedChanged: Signal;
readonly fullScreenChanged: Signal;
readonly outputChanged: Signal;
readonly maximizedChanged: Signal;
readonly captionChanged: Signal;
+56
View File
@@ -610,6 +610,7 @@ impl Table {
path,
rect: rect(&w["geo"]),
minimized: w["minimized"].as_bool().unwrap_or(false),
fullscreen: w["fullscreen"].as_bool().unwrap_or(false),
maximized: w["maximized"].as_bool().unwrap_or(false),
normal: false,
alive: true,
@@ -675,6 +676,8 @@ impl Table {
/// with no `id`, took it away from every window.
/// - `minimized`: a window was minimized or brought back ([`WindowManagerEvent::Minimize`],
/// [`WindowManagerEvent::Restore`]).
/// - `fullscreen`: a window went fullscreen or came back from it
/// ([`WindowManagerEvent::FullscreenChange`]).
/// - `maximized`: a window was maximized or restored, by the user or by komorebi's own
/// command ([`WindowManagerEvent::MaximizeChange`]).
/// - `output`: a window is on another output. It carries the frame the window has now, which
@@ -721,6 +724,7 @@ impl Table {
Some("removed") => self.on_removed(event, now),
Some("activated") => self.on_activated(event),
Some("minimized") => self.on_minimized(event),
Some("fullscreen") => self.on_fullscreen(event),
Some("maximized") => self.on_maximized(event),
Some("move_resize_step") => self.on_drag_step(event),
Some("output") => self.on_output(event),
@@ -967,6 +971,28 @@ impl Table {
})]
}
fn on_fullscreen(&mut self, event: &Value) -> Vec<Output> {
let fullscreen = event["v"].as_bool().unwrap_or(false);
let Some(id) = self.id_of(event) else {
return vec![];
};
let Some(entry) = self.windows.get_mut(&id) else {
return vec![];
};
entry.info.fullscreen = fullscreen;
if !entry.info.normal {
return vec![];
}
vec![Output::Event(WindowManagerEvent::FullscreenChange(
kwin(KWinNotification::WindowFullScreenChanged),
Window::from(id),
fullscreen,
))]
}
fn on_output(&mut self, event: &Value) -> Vec<Output> {
let Some(id) = self.id_of(event) else {
return vec![];
@@ -1460,6 +1486,9 @@ mod tests {
"screens -> Monitor Resize",
// somebody moves the editor to another output
"output -> OutputChange 1",
// the user takes the editor fullscreen and back
"fullscreen -> FullscreenChange 1",
"fullscreen -> FullscreenChange 1",
]
);
@@ -1663,6 +1692,33 @@ mod tests {
);
}
#[test]
fn going_fullscreen_and_coming_back_are_events_and_are_remembered() {
let mut t = table(vec![win("a", &["d1"])]);
let now = Instant::now();
assert_eq!(
events(&t.handle(&json!({ "type": "fullscreen", "id": "a", "v": true }), now)),
vec!["FullscreenChange 1"]
);
assert!(t.window(1).unwrap().fullscreen);
assert_eq!(
events(&t.handle(&json!({ "type": "fullscreen", "id": "a", "v": false }), now)),
vec!["FullscreenChange 1"]
);
assert!(!t.window(1).unwrap().fullscreen);
}
#[test]
fn a_window_that_is_fullscreen_when_the_script_starts_is_fullscreen() {
let mut fullscreen = win("a", &["d1"]);
fullscreen["fullscreen"] = json!(true);
let t = table(vec![fullscreen, win("b", &["d1"])]);
assert!(t.window(1).unwrap().fullscreen);
assert!(!t.window(2).unwrap().fullscreen);
}
#[test]
fn maximizing_and_restoring_are_events_and_are_remembered() {
let mut t = table(vec![win("a", &["d1"])]);
+20
View File
@@ -30,6 +30,11 @@
//! - **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. A minimize of a window komorebi
//! hid ([`struct@HIDDEN_WINDOWS`]) is not the user's, and changes nothing.
//! - **FullscreenChange**: a window that is fullscreen stays in its container but is left where it
//! is ([`Window::set_position`] does nothing for it), and the other windows are tiled as if it
//! were not fullscreen. When it comes back from fullscreen the compositor gives it the frame it
//! had before, which may not be the tile it has now, so the workspace is tiled again if it is
//! the one in front.
//! - **MaximizeChange**: a window the compositor maximized, which komorebi did not ask for (the
//! title bar, a double click, a shortcut), becomes the workspace's maximized window, as
//! `ToggleMaximize` makes the focused window, and one the user restored goes back in a container
@@ -81,6 +86,7 @@
//!
//! [`Window::should_manage`]: crate::window::Window::should_manage
//! [`Monitor::assign_windows_to_workspaces`]: crate::monitor::Monitor::assign_windows_to_workspaces
//! [`Window::set_position`]: crate::window::Window::set_position
use crate::FLOATING_APPLICATIONS;
use crate::HIDDEN_WINDOWS;
@@ -305,6 +311,20 @@ impl WindowManager {
WindowManagerEvent::MaximizeChange(_, _, maximized) => {
self.adopt_maximize_change(window_id, maximized)?;
}
WindowManagerEvent::FullscreenChange(_, _, fullscreen) => {
// While it is fullscreen the window is left alone. Coming back, KWin gives it the
// frame it had before, which may not be the tile it has now.
if !fullscreen
&& let Some((m_idx, w_idx)) = self.known_window_ids.get(&window_id).cloned()
{
let offset = self.work_area_offset;
if let Some(monitor) = self.monitors_mut().get_mut(m_idx)
&& monitor.focused_workspace_idx() == w_idx
{
monitor.update_focused_workspace(offset)?;
}
}
}
WindowManagerEvent::WorkspaceMove(_, _, idx) => {
// somebody put the window on another workspace's desktop: it belongs there now.
// (komorebi's own moves come back here too, and find the window already there.)
+43
View File
@@ -219,15 +219,29 @@ impl Window {
///
/// A window whose edge the user is dragging ([`live_resize_begins`]) is not moved: it is the
/// user's until they let go.
///
/// A fullscreen window is left where it is, and the call succeeds.
pub fn set_position(self, rect: &Rect) -> eyre::Result<()> {
// the window the user is dragging an edge of is theirs until they let go
if is_live_resized(self.id) {
return Ok(());
}
// A fullscreen window covers the monitor on purpose: moving it would take it out of
// fullscreen, or be ignored. It goes back to its tile when it leaves fullscreen.
if self.is_fullscreen() {
tracing::debug!("not moving window {}: it is fullscreen", self.id);
return Ok(());
}
backend().set_rect(self.id, rect)
}
/// Returns true if and only if the window is fullscreen
pub fn is_fullscreen(self) -> bool {
backend().window(self.id).is_some_and(|w| w.fullscreen)
}
/// Asks the compositor to draw the window with this opacity, 255 being not see-through at all
pub fn set_opacity(self, alpha: u8) -> eyre::Result<()> {
backend().set_opacity(self.id, alpha)
@@ -1022,6 +1036,35 @@ mod tests {
});
}
#[test]
fn a_fullscreen_window_is_not_moved_and_is_again_when_it_is_not_fullscreen() -> eyre::Result<()>
{
let fake = fake_with(211);
let tile = Rect {
left: 0,
top: 0,
right: 960,
bottom: 1050,
};
with_backend(fake.clone(), || {
let window = Window::from(211);
assert!(!window.is_fullscreen());
fake.set_fullscreen(211, true);
assert!(window.is_fullscreen());
window.set_position(&tile)?;
assert_ne!(window.rect()?, tile);
assert!(fake.commands().is_empty());
fake.set_fullscreen(211, false);
assert!(!window.is_fullscreen());
window.set_position(&tile)?;
assert_eq!(window.rect()?, tile);
Ok(())
})
}
#[test]
fn only_a_normal_window_that_is_alive_is_managed() {
let fake = Arc::new(FakeBackend::with_one_monitor());
+23
View File
@@ -41,6 +41,8 @@ pub enum KWinNotification {
WindowMinimized,
/// `window.minimizedChanged` to false
WindowRestored,
/// `window.fullScreenChanged`
WindowFullScreenChanged,
/// `window.maximizedChanged`
WindowMaximizedChanged,
/// `window.outputChanged`
@@ -79,6 +81,8 @@ pub enum WindowManagerEvent {
Restore(SystemNotification, Window),
/// The window's title changed
TitleUpdate(SystemNotification, Window),
/// A window went fullscreen (`true`) or came back from it (`false`)
FullscreenChange(SystemNotification, Window, bool),
/// A window is on another output than it was: the user moved it (a shortcut), or KWin did when
/// an output went away or came back. Where it is now is what the window reports.
OutputChange(SystemNotification, Window),
@@ -122,6 +126,7 @@ impl WindowManagerEvent {
| WindowManagerEvent::Minimize(_, window)
| WindowManagerEvent::Restore(_, window)
| WindowManagerEvent::TitleUpdate(_, window)
| WindowManagerEvent::FullscreenChange(_, window, _)
| WindowManagerEvent::MaximizeChange(_, window, _)
| WindowManagerEvent::OutputChange(_, window)
| WindowManagerEvent::Manage(window)
@@ -153,6 +158,7 @@ impl WindowManagerEvent {
| WindowManagerEvent::Minimize(n, _)
| WindowManagerEvent::Restore(n, _)
| WindowManagerEvent::TitleUpdate(n, _)
| WindowManagerEvent::FullscreenChange(n, _, _)
| WindowManagerEvent::MaximizeChange(n, _, _)
| WindowManagerEvent::OutputChange(n, _)
| WindowManagerEvent::MoveStart(n, _)
@@ -215,6 +221,23 @@ mod tests {
assert_eq!(event.notification(), "WorkspaceChanged");
}
#[test]
fn a_fullscreen_change_says_whether_the_window_went_fullscreen_or_came_back() {
let event = WindowManagerEvent::FullscreenChange(
SystemNotification::KWin(KWinNotification::WindowFullScreenChanged),
Window::from(303),
false,
);
assert_eq!(event.window_id(), Some(303));
assert_eq!(event.notification(), "WindowFullScreenChanged");
assert_eq!(event.to_string(), "FullscreenChange");
assert!(matches!(
event,
WindowManagerEvent::FullscreenChange(_, _, false)
));
}
#[test]
fn a_maximize_change_says_whether_the_window_was_maximized_or_restored() {
let event = WindowManagerEvent::MaximizeChange(
+170
View File
@@ -0,0 +1,170 @@
//! A window the user puts in fullscreen, against an in-memory desktop.
//!
//! The compositor covers the monitor with a fullscreen window, and komorebi leaves it there: the
//! other windows are tiled around it, and it goes back to its tile when it leaves fullscreen.
//!
//! Window ids are in the 10_000s.
use komorebi::backend::fake::Command;
use komorebi::backend::fake::FakeBackend;
use komorebi::backend::with_backend;
use komorebi::core::Rect;
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),
)
}
fn fullscreen(id: u32, on: bool) -> WindowManagerEvent {
WindowManagerEvent::FullscreenChange(
SystemNotification::KWin(KWinNotification::WindowFullScreenChanged),
Window::from(id),
on,
)
}
/// 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)
}
fn open(fake: &FakeBackend, id: u32) {
fake.open_window(id, "foot", "foot", &format!("terminal {id}"));
}
fn work_area() -> Rect {
Rect {
left: 0,
top: 0,
right: 1920,
bottom: 1050,
}
}
fn inside(rect: &Rect, area: &Rect) -> bool {
rect.left >= area.left
&& rect.top >= area.top
&& rect.left + rect.right <= area.left + area.right
&& rect.top + rect.bottom <= area.top + area.bottom
}
fn overlap(a: &Rect, b: &Rect) -> bool {
a.left < b.left + b.right
&& b.left < a.left + a.right
&& a.top < b.top + b.bottom
&& b.top < a.top + a.bottom
}
#[test]
fn a_fullscreen_window_is_left_covering_the_monitor_and_goes_back_to_its_tile_afterwards() {
let ids = [10_091, 10_092, 10_093];
// which window's tile changes when a fourth opens? (it depends on the layout; the one that
// does is the one to put in fullscreen, so that a stale frame is the wrong one)
let (control, mut wm) = desktop();
for id in ids {
open(&control, id);
}
let before: Vec<Rect> = with_backend(control.clone(), || {
for id in ids {
wm.process_event(added(id)).unwrap();
}
ids.map(|id| control.rect_of(id).unwrap()).to_vec()
});
open(&control, 10_094);
with_backend(control.clone(), || wm.process_event(added(10_094))).unwrap();
let moved = ids
.into_iter()
.zip(before)
.find(|(id, was)| control.rect_of(*id).unwrap() != *was)
.map(|(id, _)| id)
.expect("opening a window moved none of the tiles");
// the same again, with that window fullscreen while the fourth opens
let (fake, mut wm) = desktop();
for id in ids {
open(&fake, id);
}
with_backend(fake.clone(), || {
for id in ids {
wm.process_event(added(id)).unwrap();
}
});
let tile = fake.rect_of(moved).unwrap();
// the window goes fullscreen; the compositor covers the monitor with it
fake.set_fullscreen(moved, true);
let covering = fake.rect_of(moved).unwrap();
fake.clear_commands();
with_backend(fake.clone(), || wm.process_event(fullscreen(moved, true))).unwrap();
// a window opens meanwhile, and everything is retiled around the others
open(&fake, 10_094);
with_backend(fake.clone(), || wm.process_event(added(10_094))).unwrap();
assert!(
!fake
.commands()
.iter()
.any(|c| matches!(c, Command::SetRect(id, _) if *id == moved)),
"a fullscreen window was moved: {:?}",
fake.commands()
);
assert_eq!(fake.rect_of(moved), Some(covering));
// it leaves fullscreen: the compositor gives it the frame it had, which is not where it belongs
// now that there is another window, and komorebi puts it in its tile
fake.set_fullscreen(moved, false);
assert_eq!(
fake.rect_of(moved),
Some(tile),
"the fake gives back the old frame"
);
with_backend(fake.clone(), || wm.process_event(fullscreen(moved, false))).unwrap();
let now = fake.rect_of(moved).unwrap();
assert_ne!(
now, tile,
"the window is still where it was before the fourth opened"
);
assert_eq!(
Some(now),
control.rect_of(moved),
"and it is where it would have been without fullscreen"
);
for other in ids.into_iter().filter(|id| *id != moved).chain([10_094]) {
assert!(
!overlap(&now, &fake.rect_of(other).unwrap()),
"{now:?} overlaps window {other}"
);
}
assert!(inside(&now, &work_area()));
}
+8
View File
@@ -555,6 +555,14 @@ class Kde:
for state in ("MAXIMIZED_VERT", "MAXIMIZED_HORZ"):
self.sh(f"kdotool windowstate --remove {state} {shlex.quote(wid)}")
def fullscreen(self, wid: str, on: bool = True) -> None:
"""Asks KWin to take the window fullscreen, or with `on` false to bring it back from it.
It is the user's fullscreen, as the program's own key does it: the daemon is not asked.
"""
flag = "--add" if on else "--remove"
self.sh(f"kdotool windowstate {flag} FULLSCREEN {shlex.quote(wid)}")
def close(self, wid: str) -> None:
"""Asks KWin to close the window, as its title bar button would."""
self.sh(f"kdotool windowclose {shlex.quote(wid)}")
+4
View File
@@ -273,6 +273,8 @@ def test_window_actions_go_through_kdotool_inside_the_session(machine: FakeMachi
kde.minimize("{a}")
kde.maximize("{a}")
kde.unmaximize("{a}")
kde.fullscreen("{a}")
kde.fullscreen("{a}", on=False)
kde.close("{a}")
assert [inner(command) for command in machine.commands] == [
@@ -282,6 +284,8 @@ def test_window_actions_go_through_kdotool_inside_the_session(machine: FakeMachi
"kdotool windowstate --add MAXIMIZED_HORZ '{a}'",
"kdotool windowstate --remove MAXIMIZED_VERT '{a}'",
"kdotool windowstate --remove MAXIMIZED_HORZ '{a}'",
"kdotool windowstate --add FULLSCREEN '{a}'",
"kdotool windowstate --remove FULLSCREEN '{a}'",
"kdotool windowclose '{a}'",
]
assert all("WAYLAND_DISPLAY=wayland-main" in command for command in machine.commands)
+63 -4
View File
@@ -11,14 +11,17 @@
# container that does not have the focus, a stack and a monocled window that have it, and a
# floating window that has it
# D animation curves: two curves put a window in different places at the same moment
# E fullscreen: a fullscreen window covers the monitor, is not moved, and goes back to its tile
#
# Every window is a `foot` of one color all over (`Kde.colored_window`), and a color is read at the
# pixels just outside the frame of the window (`pixels.ring`). `komorebi/tests/maximize.rs` and
# `maximize_commands.rs` check what the window manager does with a maximize against the fake;
# section A checks that KWin's own maximize does it.
# pixels just outside the frame of the window (`pixels.ring`); section E reads the frames KWin
# reports for the windows, not a screenshot. `komorebi/tests/maximize.rs`, `maximize_commands.rs`
# and `fullscreen.rs` check what the window manager does with a maximize and a fullscreen against
# the fake; section A checks that KWin's own maximize does it, and section E that a window KWin has
# taken fullscreen is left alone.
import time
from komorebi_vm import Checks, Kde, pixels
from komorebi_vm import Checks, Kde, Rect, pixels
UNIT = "komorebi.service"
# the daemon's log of the day, without colors, which every run of the unit adds to
@@ -313,4 +316,60 @@ print(f" {expo}% with EaseOutExpo")
checks.check("Linear is less than 60% of the way", 0 <= linear < 60)
checks.check("EaseOutExpo is at least 20 points further along than Linear", expo >= linear + 20)
print("== E. fullscreen")
# This section needs the daemon running and a red, a green and a blue window open and tiled, and
# does not depend on which section came before it: when one has stopped the daemon or closed the
# windows, they are started and opened again.
if not answering():
kde.systemctl("start", UNIT)
kde.wait_for(answering, timeout=60)
for title, background in [("red", "ff0000"), ("green", "00ff00"), ("blue", "0000ff")]:
if kde.wid(title) == "":
open_window(title, background)
kde.wait_for(lambda: kde.managed() == 3, timeout=30)
kde.wait_for(lambda: kde.focused_title() == "blue", timeout=20)
blue = kde.wid("blue")
monitor = kde.state()["monitors"]["elements"][0]["size"]
whole = Rect(monitor["left"], monitor["top"], monitor["right"], monitor["bottom"])
# the bridge asks again for the position of a window that is not where komorebi put it for a second
# after it did, which is not the thing tested here
time.sleep(3 * SCALE)
kde.fullscreen(blue)
checks.check(
f"the window covers the monitor, panels included ({kde.rect(blue)}, the monitor is {whole})",
kde.wait_for(lambda: kde.rect(blue) == whole, timeout=20),
)
open_window("yellow", "ffff00")
checks.check("komorebi manages the fourth window", kde.wait_for(lambda: kde.managed() == 4))
time.sleep(3 * SCALE)
checks.check(
f"a window opening meanwhile does not take it out of fullscreen ({kde.rect(blue)})",
kde.rect(blue) == whole,
)
kde.fullscreen(blue, on=False)
def in_a_tile(r: Rect) -> bool:
"""Smaller than the monitor, and inside it."""
return (
r.w < whole.w - 100
and r.x >= whole.x
and r.y >= whole.y
and r.x + r.w <= whole.x + whole.w
and r.y + r.h <= whole.y + whole.h
)
checks.check(
"back from fullscreen it is in a tile",
kde.wait_for(lambda: in_a_tile(kde.rect(blue)), timeout=20),
)
back = kde.rect(blue)
print(f" blue is back at {back}")
checks.check(
"and it does not sit on any other window",
not any(back.overlaps(kde.rect(kde.wid(other))) for other in ("red", "green", "yellow")),
)
checks.check("komorebi still has all four", kde.managed_all() == 4)
checks.finish()