Commit Graph
18 Commits
Author SHA1 Message Date
LGUG2Z ac265de1e3 feat(wm): padding for named workspaces
workspace-padding and container-padding take the index of a workspace's monitor
and its own index, so a script that names its workspaces still has to count them
to pad one, while focus-named-workspace, named-workspace-layout and
named-workspace-tiling already take the name.

named-workspace-padding and named-workspace-container-padding take the name
instead. They look the workspace up on every monitor the way the other named
commands do, set the same padding the commands by index set, and change nothing
for a name no workspace has.

The tests check the messages the subcommands send, that a named workspace takes
its paddings and the tile in front follows, and that a name nobody has changes
nothing. The commands sweep of the VM suite sends both commands to a real
session.
2026-10-28 07:20:37 -07:00
LGUG2Z 189aeb1005 feat(wm): more than one monitor
Every monitor has workspaces of its own, which KWin's per-output desktops show
independently, so a second monitor is mostly the window manager's business:
which monitor has the focus, and how a window gets from one to the other.

At the edge of a monitor, focus and move now cross to the monitor on that side,
where they used to fail with "there is no container in this direction": the
focus lands on the container nearest the monitor it came from, and a move swaps
with the container at that edge (cross-monitor-move-behaviour insert puts it in
among the containers there, no-op keeps it). A window that opens or gets the
focus on a monitor that is not the focused one focuses that monitor, which also
makes the scrolling test of a strip next to another monitor guard real code.

The monitor commands come with it: focusing a monitor by index, in order or
under the pointer, a workspace of another monitor, moving and sending the
focused window to another monitor, to a workspace of it or to a named workspace
(in the window manager these are moves between monitors, so they were left out
of the workspace commands), moving and swapping workspaces between monitors, and
a display index preference that keeps a monitor at the index the user gives it.
eager-focus focuses the first window of an executable on any workspace of any
monitor; its window is found in a container or among the floating windows, the
two places a window can be until stacks, monocle and maximize exist.
2026-10-28 07:20:37 -07:00
LGUG2Z 8467b03ac2 feat(wm): named workspaces and the other workspace commands
Workspace i is KWin's desktop i, and switching and moving by index came with the
workspaces. What a user binds keys to besides is the rest of komorebi's
workspace commands: going to the next or the previous workspace (or the next
empty one), back to the last one, adding and closing workspaces, showing one
index on every monitor, and setting the layout or the tiling of a workspace that
need not be in front. They are made of the same two operations, showing a
workspace and moving a container to one, so KWin keeps doing the showing and the
hiding.

A workspace gets a name, which focus-named-workspace, named-workspace-layout and
named-workspace-tiling find it by on any monitor; focusing a named workspace
focuses its monitor too, so focus_monitor comes with it. The name is komorebi's:
naming a workspace does not rename KWin's desktop. The query
focused-workspace-name answers with the name, or the index of a workspace that
has none.

The moves to a named workspace, and everything that addresses a monitor, are
moves between monitors in the window manager and come with more than one
monitor.
2026-10-28 07:20:37 -07:00
LGUG2Z 62e37e44cf feat(kwin): workspaces are KWin's own desktops
komorebi's workspaces exist on Windows and macOS because neither has a virtual
desktop API: switching hides every window of the workspace being left. KWin has
virtual desktops, per output since 6.8, so here workspace i is KWin's desktop i
on every monitor, and each monitor shows one. KWin shows, hides and focuses the
windows of a desktop; komorebi asks for a switch, puts a window on a desktop,
and follows what anything else does.

The Backend gets the workspace methods (ensure_workspaces, switch_workspace,
set_window_workspace, window_workspace, current_workspaces), and the fake its
desktops. The bridge reports the desktops, the desktop each output shows and a
window's desktops, and carries out output_desktop and desktop commands, typed
and tested with the fixtures like the rest. The table turns a switch or a move
komorebi did not make into a WorkspaceChange or a WorkspaceMove, and records its
own when it sends them, so that KWin's report of those raises nothing. The
window manager follows a switch with no command in reply
(adopt_workspace_change), puts windows on their desktops before a desktop is
shown (focusing a window first would take the monitor back), and loads the
windows open at startup onto the workspace of their desktop.

Per-output desktops are a kwinrc setting that is off by default, and
kwriteconfig6 was not on the PATH of the user service in a live session, so
komorebi edits kwinrc itself (kwin::kwinrc) and puts the user's value back when
it stops, also after a crash.

focus-workspace, move-to-workspace and send-to-workspace come with this commit,
since the tests of switching and moving, and vm-workspace-switch, need them; the
named and cycling workspace commands follow. vm-workspace-switch runs the daemon
against a real KWin; vm-kwin-contract checks what komorebi relies on from KWin's
per-output desktops, on a headless KWin with two outputs and no komorebi.
2026-10-28 07:20:37 -07:00
LGUG2Z 1b10611022 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.
2026-10-28 07:20:37 -07:00
LGUG2Z c95be9391e feat(wm): padding and work-area offsets
Every workspace was tiled with the default padding of 5 pixels around and
between its tiles, and the work area offsets could not be set. The padding
commands set a workspace's own workspace padding and container padding, by the
indices of its monitor and itself or for the focused workspace, and the adjust
commands make the focused workspace's larger or smaller.

`global-work-area-offset`, `monitor-work-area-offset` and
`workspace-work-area-offset` keep a part of the work area free of tiles, and a
workspace's offset now wins over its monitor's, which wins over the global one.
A monitor's window-based offset applies while the workspace in front has no more
windows than the monitor's limit, and `toggle-window-based-work-area-offset`
turns it off and on for the focused workspace.

The vm-commands sweep sets and resets every offset and padding.
2026-10-28 07:20:37 -07:00
LGUG2Z 56294e1cd3 feat(wm): manage, minimize and close
Every normal window KWin reports is tiled, and the only way to take one out of
the layout was to float it, which keeps it a window komorebi manages. `komorebic
unmanage` takes the focused window out of the layout, and komorebi leaves it
alone until `komorebic manage` takes it back or the window closes. Both are
events komorebi raises itself on the channel KWin's events go through, so they
are handled in order with what KWin reports. `komorebic minimize` and `close`
ask KWin to minimize or close the focused window, which leaves the layout when
KWin reports that it did.

`komorebic toggle-pause` pauses the daemon: it acts on no event, and takes only
`toggle-pause`, `state` and `stop` on its socket until it is resumed. With
`unmanaged-window-operation-behaviour no-op`, the daemon refuses every message
while the focused window is not one it tiles.

The vm-commands sweep pauses and resumes, sets the operation behavior, manages
and closes the focused window, and a section of its own minimizes, unmanages and
manages a window.
2026-10-28 07:20:37 -07:00
LGUG2Z 3cc5dceb7d feat(wm): lock
Containers can be locked since the model came in, and a locked container keeps
its index while others are inserted, removed or swapped around it; nothing could
lock one. `komorebic toggle-lock` locks the focused container, or unlocks it,
and the `LockMonitorWorkspaceContainer` and `UnlockMonitorWorkspaceContainer`
messages lock and unlock a container by the index of its monitor, its workspace
and itself, for a program such as a status bar.

The vm-commands sweep locks and unlocks a container.
2026-10-28 07:20:37 -07:00
LGUG2Z 062fe57fac feat(wm): float
Some windows are better left out of the layout. `komorebic toggle-float` takes
the window that has the focus out of its container into the floating windows of
the workspace, which komorebi gives no tile and does not move, and the other
windows share the work area; on a floating window it puts it back in a container
at the focused place.

A floating window is in the index of managed windows like any other, so its
events find it, and it is in the state as the workspace's `floating_windows`.
Where a floated window goes is the `toggle_float_placement` of the window
management behaviour: by default it stays where it is, since the spike's
centering of floated windows waited for a configuration file. Centering and
resizing to the floating aspect ratio come with it.

Opening windows floating (the float override and the float rules), the floating
layer and its own focus and move commands come later.

The vm-commands sweep floats a window and puts it back, and its health check
counts the floating windows.
2026-10-28 07:20:37 -07:00
LGUG2Z 554d77aed9 feat(wm): promote
The first tile of a layout is its largest, and the place for the window being
worked on. `komorebic promote` moves the focused window there, with the windows
that were before it each moving one tile on; `promote-swap` swaps it with the
window that has the first tile, and from the first tile swaps it back with the
window it was last swapped with; `promote-window left|right|up|down` promotes
the window on that side of the focused one; and `promote-focus` gives the focus
to the first tile and moves nothing.

The grid layout has no largest tile, so the four do nothing there.

The vm-commands sweep gains the four.
2026-10-28 07:20:37 -07:00
LGUG2Z 95eb4d5039 feat(wm): resize from the CLI
`komorebic resize-edge left|right|up|down increase|decrease` moves one edge of
the focused tile, and `resize-axis` both edges along an axis, by the delta that
`resize-delta` sets (50 pixels to start with; komorebi-layouts moves an edge by
half of it). The neighbors give up the room.

A resize is an adjustment of the container's tile that the workspace keeps, one
per container, and that the layout applies whenever it tiles. Before each
tiling, the workspace clears the parts its layout cannot apply, such as the
height of a column. No tile may end up narrower or shorter than a tenth of the
work area, or than it already is: a resize that would make one so is granted as
far as it can go, not dropped.

`retile` drops the adjustments, and the `RetileWithResizeDimensions` message
tiles with them. `save-resize` and `load-resize` write them to a file and read
them back, and `quick-save-resize` and `quick-load-resize` do the same with one
file in the daemon's temporary directory.

The vm-commands sweep gains every resize command, and the saving and loading.
2026-10-28 07:20:37 -07:00
LGUG2Z 37b06993d2 feat(wm): tune layouts, and the scrolling layout
`komorebic layout-ratios --columns ... --rows ...` sets the share of the work
area the first columns and rows of the focused workspace's layout take, and
`scrolling-layout-columns N` how many columns of the scrolling layout are in
view. Both are layout options of the workspace, which komorebi-layouts reads.

The scrolling layout is a strip of columns that runs past the edges of the work
area, so its tiles are not clamped to it: the windows of the columns out of view
are off the screen. When a window of a scrolling workspace gets the focus, the
workspace is tiled after the focused container changed, which scrolls the strip
to it.

KWin moves a window anywhere, but keeps 100 pixels of a window it resizes on an
output once the client has drawn at the new size, which pulls the columns out of
view back onto the screen. The bridge remembers the position it was asked for,
for a second, and asks again for the position alone when KWin moved the window
elsewhere, three times at most.

komorebi-layouts compiles `Rect::percentage_within_horizontal_bounds` only with
its darwin feature, which this repository does not enable, so the workspace has
its own copy of it.

A new VM suite, vm-scrolling, after the spike's m2-scrolling script, checks in a
real KWin that the columns out of view stay where komorebi put them and that the
strip follows `komorebic focus`; vm-commands sweeps the two new commands.
2026-10-28 07:20:37 -07:00
LGUG2Z 41c32aad8e feat(wm): layouts
Every workspace started in BSP and stayed there. `komorebic change-layout` gives
the focused workspace one of the layouts of komorebi-layouts, `cycle-layout
next|previous` steps through them, and `flip-layout` mirrors the tiles along an
axis, on top of the flip there is. A flip mirrors the tiles, not the order of
the containers, and the directions of focus and move are flipped with it, so
`focus left` still goes to the tile on the left.

`toggle-tiling` turns the tiling of the focused workspace off and on: while it
is off, no window of the workspace is moved, and the windows that open are taken
in and tiled once it is on again.

`preselect-direction` reserves the place on one side of the focused window for
the next window: an empty container holds it, the layout gives it a tile, and
the next window takes it. `cancel-preselect` gives it back.

A new VM suite, vm-commands, after the spike's m8 script, runs every layout with
five, three, two and one windows against a real KWin with two outputs, and then
every komorebic command there is, checking after each that the daemon is alive,
answers, and has every window KWin has, and that KWin has the windows where the
daemon asked. Every command that comes later adds its line to that sweep.
2026-10-28 07:20:37 -07:00
LGUG2Z 1bbb534844 feat(wm): move and cycle containers
With the focus moving, the next thing a key has to do is move the focused
window. `komorebic move left|right|up|down` swaps the focused container with the
one whose tile is on that side, as the layout sees it, and `komorebic cycle-move
next|previous` with the next or the previous one in the workspace's order. The
workspace is tiled again, the window that moved keeps the focus, and the pointer
follows it.

The swap goes through the lockable sequence, so a lock stays with its index: the
container moved onto a locked index is locked in its place.

Moving towards a side with no window fails and moves nothing. Moving into the
next monitor comes with more than one monitor, and the floating layer's own move
with the floating layer.

The VM test moves a window in a real KWin and back, and checks that KWin has
every window where komorebi asked.
2026-10-28 07:20:37 -07:00
LGUG2Z 20f93ba8c4 feat(wm): focus in a direction
A tiling window manager is driven from the keyboard, and the first thing a key
has to do is move the focus. `komorebic focus left|right|up|down` gives the
focus to the window whose tile is on that side of the focused one, as the layout
sees it, and `komorebic cycle-focus next|previous` to the next or the previous
window in the workspace's order, wrapping around. Neither moves a window: the
focus changes, the tiles stay.

The pointer follows the focus, through KWin's "Move Mouse to Focus", unless
`mouse-follows-focus disable` (or `toggle-mouse-follows-focus`) says not to.

Focusing towards a side with no window fails and changes nothing. Crossing into
the next monitor, as komorebi does on the other platforms, comes with more than
one monitor; so do eager-focus and the parts that depend on the monocle,
maximized and floating windows.

The VM test now drives the focus with komorebic in a real KWin and checks that
KWin and komorebi agree on the focused window afterwards.
2026-10-28 07:20:37 -07:00
LGUG2Z a86e2bdc63 build(nix): a NixOS module for komorebi
On NixOS, running komorebi should take one flake input and one module line. The
flake exports `services.komorebi` as nixosModules.default (and .komorebi). It
adds the flake's overlay, which gains komorebi-full, installs komorebi and
komorebic, and runs the daemon in every Plasma session as a systemd user unit,
wanted by plasma-workspace.target and ordered after plasma-core.target, which
Plasma orders after KWin. systemd starts a daemon that fails again; the exit
status of a SIGTERM (130) counts as success, so a stopped unit is not failed.
The unit's PATH is the one a terminal in the session has, instead of the few
store paths NixOS gives a unit.

The options are enable, package, logLevel and a read-only execStart, the command
line the unit runs. There is no configFile: the daemon reads no configuration
file yet, and an option that linked one would do nothing.

nixos-module-eval evaluates seven scenarios as real NixOS systems and checks
their assertions, the unit and execStart, with builtins.tryEval for the two that
must not evaluate. Nothing in it builds a Linux package, so it runs on a Mac as
well.

The VM nodes import the module, so every suite runs komorebi as the module
installs it. The headless node's komorebi@NAME runs execStart, which has
--log-level, so the nodes set logLevel to debug and the suites stop passing it;
latency, which times the daemon, keeps the default level. The session node
starts the module's unit at login; render-smoke turns it off, since a tiled
terminal would cover the desktop it looks at. A new fast suite, vm-module, logs
in to Plasma and checks that the unit runs the daemon with the module's log
level and PATH, that a window opened in the session is tiled, that a killed
daemon is started again, and that a stop is not a failure.

docs/installation.md covers NixOS first, and building from source on other
distributions. There is no binary cache: the Komorebi license does not allow
third parties to redistribute binaries.
2026-10-28 07:20:37 -07:00
LGUG2Z e2d757bbb0 feat(wm): subscribe to notifications
A program such as a status bar has to follow the window manager, and so far it
could only ask: komorebi on Windows and on macOS sends notifications for that.
The daemon now keeps subscribers, each a Unix socket in its data directory that
a program listens on, and after each event and each message it handles, it
connects to every subscriber and writes the event or the message with the state
after it.

A subscriber can ask to be sent only what changed the state; a window that is
shown and a new subscription are always sent, so a subscriber has the state as
soon as it subscribes. A subscriber that cannot be connected to is dropped with
its socket file and not tried again, and a stopping daemon closes an empty
connection to each one.

komorebic gains subscribe-socket and unsubscribe-socket, and komorebi-client
subscribe and subscribe_with_options, which bind the socket and subscribe. The
tests subscribe through real sockets against the fake desktop: a shown window is
sent with the state that has it, a subscriber that filters is not sent a
question that changed nothing, and one that is gone is dropped.
2026-10-28 07:20:37 -07:00
LGUG2Z 6f657e53e3 feat(cli): komorebic
The daemon answers on its socket, but a person has no way to write a message on
it. komorebic is that way: each subcommand that asks something of the daemon is
one message, sent with komorebi-client, and a question's answer is printed.
komorebic is a separate program because the daemon takes no input but its
socket, so a key binding, a script and a terminal all drive it the same way.

It has what the daemon can answer so far: state, monitor-information, query,
retile and stop. Around them, start runs the komorebi on the PATH in the
background and passes it what follows a --, such as --fake-backend to try it
without a compositor; log follows the daemon's log, which the daemon now writes
to a file a day in LOG_DIR as well as to stdout; data-directory shows where the
socket is; completions prints a shell completion script. The hidden docgen
writes each subcommand's help to docs/cli, in fences that name their language
and wrapped at 100 columns whatever the width of the terminal, so that the pages
are the same wherever they are generated; just docgen runs it.

The user guide starts here: docs/index.md for KDE Plasma, and a page on running
komorebi. tests/cli.rs runs each subcommand against a socket the test serves in
place of the daemon. In the VM tests the guests get komorebic next to the
daemon, and vm-core-e2e reads the daemon's state with it (the windows it
manages, the one it focuses, after KWin restarts too) and stops it with
komorebic stop, which unloads the bridge and removes the socket.

In the VM test library, Kde.state asks again, a second (times the scale) after
an empty answer or a failed command, up to 10 times. The daemon drops a query
when it cannot take hold of the window manager within a second, and komorebic
then prints an empty line and succeeds; komorebic itself gives the daemon a
second to answer and fails when that is not enough. A daemon busy with the first
windows does either on a machine that is loaded or slow, and a suite that polled
the state died on JSONDecodeError. An answer that is not JSON still fails at
once.
2026-10-28 07:20:37 -07:00