kdos

The window model

This chapter explains the rules that decide where a window goes on the KDOS desktop: where a new window opens, what tiling and snapping do to it, which edge it stops against when it is pushed, how a dialog travels with the window that opened it, where a window reopens after it was closed, and which workspace a switch lands on. It is written for anyone who wants to know why a window ended up where it did, and for contributors changing the compositor, kdos-comp, or the library that holds its geometry, libkwm.

If you only want the keys and mouse gestures, read The desktop first; this chapter explains what those gestures do underneath. Contributors should read What the library is, and is not, The library’s entry points and The contract before changing any rule described here.

Two terms run through the whole chapter. An output is a screen. Its usable area is that screen less the space panels reserve for themselves, and every rectangle below is measured inside it. A margin is the thickness of the decoration the compositor draws around a window: the title bar on top and the border on the other three sides.

What the library is, and is not

KDOS keeps the geometry of window management, the arithmetic, in a small C library, libkwm (src/libs/libkwm, four source files and one header), outside the compositor that obeys it. The compositor, kdos-comp, is a fork of the labwc Wayland compositor; see kdos-comp, and How KDOS differs for why KDOS ships one desktop of its own rather than an existing one.

libkwm is handed rectangles and told what is being asked. It knows nothing about windows.

The library answers The caller still owns
Where a new window lands among the ones already there What a window is, and which output it is on
What the tiled state becomes, and what rectangle that state occupies Whether the window is maximised, and whether a client accepted the size
Which other edge a moving edge meets first Walking the window list, deciding which edges are visible, and reading how thick the decoration is
The nearest occupied workspace in one direction Which workspaces exist, and what “occupied” means
Whether a press-and-move has become a drag Which surface the press was on

That split is what lets every rule be checked against a table of rectangles with no compositor and no display running. The library’s entry points lists where each answer is used, and The contract describes the table.

Focus and stacking

A window takes the keyboard when you click it. A left, right or middle press inside a window focuses it and raises it; this is a compositor default binding, and the shipped ~/.config/kdos-comp/rc.xml keeps it by starting its mouse bindings with <default />. Focus does not follow the pointer: the shipped rc.xml does not set <focus><followMouse>, and the compositor’s default is off.

A shaded window is rolled up into its title bar: the frame stays where it was and the content is hidden. The shipped rc.xml shades a window when the wheel turns up over its title bar and unshades it when the wheel turns down; the wheel-up binding also takes the keyboard away from the window, and the wheel-down binding gives it back. A double-click on the title and Super+s toggle shading.

Super+m toggles maximise and Super+f toggles fullscreen. Maximise is a compositor state of its own, per axis: see Opposing edges are not a tile.

Tiling is two steps

Tiling a window answers two separate questions: which state the window goes to (left half, top-right quarter), a decision over a bitmask of edges (kwm_tile_next()), and which rectangle that state covers on this screen, arithmetic over the usable area (kwm_tile_geom()). Keeping them apart is what makes each testable on its own.

The edge bits are TOP, BOTTOM, LEFT, RIGHT and CENTER, with the same values as the compositor’s own enum lab_edge, so the compositor passes its enum straight in. Changing a value on one side without the other is a silent geometry error.

The transition

The transition splits the current tiled state into the part parallel to the direction of the snap and the part orthogonal to it. The shipped rc.xml binds Super+Left, Super+Right, Super+Up and Super+Down to SnapToEdge with combine="yes":

Window is You press It becomes Why
Untiled Super+Left Left half The request is taken as it is
Left half Super+Up Top-left quarter A half plus an orthogonal edge is a quarter
Top-left quarter Super+Down Left half Snapping away from the edge it holds on that axis drops that edge and leaves the half on the other axis
Top-left quarter Super+Up Top half See below
Left half Super+Right Right half The opposite edge replaces the one it had
Left half Super+Left Right half, on the screen to the left See below

The fourth row is the least obvious. A quarter snapped towards the edge it already occupies collapses to a half: the parallel part is then neither the opposite of the request nor absent, so no combining rule applies, the request is taken unchanged and the orthogonal part is dropped. That is the compositor’s behaviour and the fixture pins it.

The last row is snapping across outputs, which every SnapToEdge action asks for (a drag to a screen edge does not). When the window is already tiled to exactly the requested edge, the compositor moves it to the neighbouring screen in that direction with the edge inverted, so a left-tiled window snapped left again lands right-tiled on the screen to its left. With no usable screen there, nothing changes. This rule applies whether combine is on or off.

With combine off, the request always wins and a quarter is never built. A request that is not a single edge (a corner asked for directly, CENTER) is taken as given, and a window tiled to CENTER never combines.

Two cases never reach the library, because they are about window state. A maximised window is unmaximised first and takes the requested edge unchanged. A fullscreen window ignores SnapToEdge entirely.

ToggleSnapToEdge is the same action, except that a window already tiled to exactly the requested edge is untiled and returned to its restore rectangle instead. The shipped rc.xml does not bind it.

The gap arithmetic

The two halves of an axis come from different expressions: (size + gap) / 2 and (size - gap) / 2. That puts one whole gap between two tiled windows rather than half a gap each, and it means an odd dimension gives the right or bottom half one extra pixel. The extra pixel is intended; the fixture pins it.

The gap is <core><gap> in ~/.config/kdos-comp/rc.xml; KDOS ships it at 0, which is also the compositor’s default.

Every tiled rectangle is then inset by the margin on each side, so the title bar and border fit inside the tile. A state that has none of the four edge bits (CENTER, or no state at all) is the whole usable area inset by the gap and the margin. The centre state is therefore shaped like a maximised window, not centred.

Opposing edges are not a tile

A state holding both edges of one axis collapses that axis. Left sets the far bound to the midpoint and right sets the near bound to the midpoint, so together they give a width of zero with no gap, and a negative one once a gap or the margins come off.

That is not a tile the model defines. The transition never produces an opposing pair, so the fixture has no row for one and none should be invented. Maximise is not built from all four edges either: the compositor keeps it as its own state (view->maximized, per axis: horizontal, vertical or both) and computes its rectangle from the usable area directly.

A drag that ends against an edge

Dragging a window by its title bar and letting go near a screen edge snaps it there. The pointer counts as at an edge when it is within 10 pixels of the usable area’s edge (<snapping><range inner="…" outer="…">, both 10 by default; the inner value applies where another screen lies beyond that edge, the outer value where none does). Within 50 pixels of a corner along that edge (<snapping><cornerRange>) it counts as the corner, and the window takes the matching quarter. A preview of the target is drawn after the pointer has rested there for 500 milliseconds (<snapping><overlay><delay inner="…" outer="…">, both 500 by default). The shipped rc.xml sets none of these, so the compositor defaults apply.

The drag snaps with combining turned off, so the edge the pointer landed on is taken as it is: a window dragged to the right edge becomes the right half, never a half of a half. A tiled or maximised window stays where it is until the pointer has moved 20 pixels (<resistance><unSnapThreshold>). A window maximised on one axis only is held until the pointer has moved 150 pixels along the axis it is maximised on (<unMaximizeThreshold>); along the free axis it moves at once and stays maximised. Past the threshold, the window returns to its restore rectangle under the pointer and floats for the rest of the drag.

The top edge alone is different: it maximises. That is <snapping><topMaximize>, default yes. A top half is available from Super+Up.

One restore rectangle

Each window keeps a single restore rectangle (labwc calls it the “natural geometry”): the size and place an untile, unmaximise or un-fullscreen returns to. It is written only from an untiled rectangle. Snapping, maximising and going fullscreen all refuse to overwrite it while the window is tiled or fullscreen, and a window maximised on one axis records only the other axis. Leaving fullscreen re-derives a tiled window’s rectangle from its tiled state rather than replaying a stored one.

Fullscreen is independent of the tile. If going fullscreen overwrote the restore rectangle, a later untile would return the window to the tile it is already in. Re-deriving on the way out is also what puts a window that was fullscreen across a panel change or a screen resize into the tile the new layout gives.

A change of screen layout (a screen connected, removed, resized or moved) keeps each window’s state. A tiled, maximised or fullscreen window has its rectangle derived again from that state on the screen it was on, or on the screen it is moved to when that one is gone; a floating window keeps its position relative to its screen (or its place in the whole layout when that screen is gone) and is pulled back on-screen if it would fall off. That is view_adjust_for_layout_change() in src/desktop/kdos-comp/src/view.c.

Placement is a search, not a cascade

A new window that did not place itself is placed by minimising its overlap with the windows already on its screen. This is labwc’s Automatic placement policy and the compositor’s default; <placement><policy> in rc.xml also accepts Center, Cursor and Cascade, and only Automatic uses libkwm. The shipped rc.xml does not set it. The algorithm is Openbox’s minimal-overlap placement, transcribed rather than reinvented, because the fixture records where it puts windows and a tidier search would put them somewhere else.

The search works like this:

  1. Every window on that output and on the current workspace is taken as a box, already enlarged by its own margin. A shaded window counts only as its title bar.
  2. The edges of those boxes are extended across the whole usable area, which divides it into an irregular grid. Every cell of the grid is then either wholly covered by a given window or not covered by it at all, which is what makes the overlap count exact.
  3. Each cell is counted for how many windows cover it.
  4. The new window, enlarged by its margin and by the gap on both sides, is slid across the grid in four directions, and its overlap is the covered area it would sit on, weighted by that count. The first position with no overlap at all ends the search. Otherwise the position with the least overlap wins.

With nothing else on the output, if the grid cannot be allocated, or if the window is larger than the usable area, it lands in the upper-left corner, inset by both its margin and the gap.

The decoration margin has four sides

KwmBorder is top, right, bottom, left, and every caller fills in all four. kdos-comp measures in pixels and hands in its title bar’s height and its border’s width, which are not the same number. A caller that passed one number four times would give the model a margin its own frames do not have, and every placed window would be off on one axis.

Where the margin’s cost falls depends on which rectangle is fixed:

A thicker border therefore costs space in every maximised and tiled window, and none in a floating one.

A placement keeps the frame on the screen

A content rectangle that fits the usable area can still put its title bar and borders off the edge. A frame drawn off-screen is a window with nothing to grab: no title bar to drag, no border to resize. So:

Window memory

Window memory saves each application’s rectangle, workspace and shaded state when its window closes, and puts them back the next time a window with the same application id opens. It is window_memory in ~/.config/kdos/comp.conf, on by default, and lives in the compositor (src/desktop/kdos-comp/src/kdos-winpos.c), not in libkwm. The records are kept in $XDG_STATE_HOME/kdos/winpos, most recent first, and only the 200 most recent applications are kept.

It applies only to a window the compositor would otherwise place itself. These are left alone:

A remembered rectangle may come from a screen of another size, so it is clamped into the usable area of whichever screen its centre lands nearest: first shrunk to fit, then moved inside. The clamp applies to the content rectangle, not the frame, so a window remembered against the top or left edge can reopen with its title bar outside the usable area. If another window of the same application already sits at that position on the same workspace, the remembered rectangle is not used and the overlap search places the new window, so a second instance does not open exactly on top of the first.

A window that belongs to another window

One program is not one window. An image editor’s toolbox, its image window, two docks and a modal file chooser can be five windows over one connection. The compositor therefore has words for how they relate:

This relation belongs to the compositor. libkwm never sees it.

Where an owned window opens

A Wayland window with an owner opens centred on its owner, at the size it asked for, shrunk first if that is larger than the usable area, and kept inside the usable area. It does not go through the overlap search: a dialog belongs next to the window that raised it, not wherever there happened to be room. An X11 window follows the X11 rules instead: one that gives a position keeps it, and one that does not is placed by the placement policy like any other window.

Window memory neither records nor restores a window with an owner. Every window of one program carries the same application id, so a remembered file chooser would be saved as the rectangle the program’s main window opens at next time.

Raising, lowering and minimising a family

You do this What happens
Raise any member (click, focus, the switcher) The top-level owner is raised first, then every owned window in the order they were already stacked, then the window you named goes on top of them all. Nothing else can come between a dialog and its owner
Focus any member when the family has a modal dialog The keyboard goes to the modal dialog, not to the window you clicked, whichever way the family was focused
Lower (the Lower action, bound to no key or button in the shipped configuration) The owned windows go to the bottom, then the owner below them
Minimise any member The whole family is minimised, whichever member asked. Restoring brings the whole family back

Owned windows keep their stacking order among themselves on a raise because they are collected back to front and each is moved to the front in turn.

What a family does not share

These act on one window, not on its family:

A modal dialog is modal to its application, not to the machine: every other window carries on.

Tab groups

Tab groups are a relation separate from ownership: the compositor can stack windows as tabs. The AddToTabGroup action folds the focused window onto the one behind it, RemoveFromTabGroup takes it out, and NextInTabGroup steps through the tabs; clicking a tab on the title bar shows that member. Hidden members are minimised windows sharing the shown member’s rectangle, so the taskbar reports them as minimised with no extra code. Dragging a tab moves the member it names; it does not tear the member out of the group. None of the three actions is bound to a key in the shipped rc.xml. Tabs stack; they do not tile. See Decisions.

The edge search is one question

If this edge moves in this direction, which other edge does it meet first? Three keyboard commands are built on that question:

Command Shipped keys What it does
MoveToEdge Super+Alt+arrow Moves the window until it meets the next edge
GrowToEdge Super+Ctrl+arrow Moves the window’s leading edge out to the next edge
ShrinkToEdge not bound Pulls the window’s trailing edge in to the next edge

MoveToEdge first aims for the edge of the screen’s usable area, less the gap and the margin, and then stops short of it at the first window edge in the way. Its snapWindows attribute, yes by default, can be set to no to consider the screen edge alone.

kwm_edge_best() answers “which of these two candidates is nearer, the way I am moving”; the caller supplies everything else. An edge that does not exist on a side is INT_MIN or INT_MAX, and the addition and subtraction saturate (kwm_clip_add(), kwm_clip_sub()) so those values survive the arithmetic rather than wrapping. A bounded edge always beats an unbounded one. An edge counts if it lies between where the moving edge is and where it is going: the target inclusive, the current position exclusive, so an edge the window is already sitting on is not a stopping point.

Two windows that meet side by side, their opposing edges touching, end up one gap apart. Two windows lined up on the same side, aligned edges, end up flush. To get that result the compositor pads the other window’s aligned edges by the gap before asking, because the moving window’s own rectangle already includes it.

An edge the compositor reports as not visible is pushed out of bounds rather than dropped, so it loses every comparison without the search needing a special case. Working out what is visible needs the scene graph (the compositor’s tree of drawn nodes), so that stays in the compositor, as does the walk that finds candidate edges at all. libkwm holds the arithmetic, not the walk.

More than one screen

The layout is one coordinate space, so a window dragged past the right edge of one screen is on the next; there is no boundary in the space itself to stop at.

A keyboard move stops at a screen edge before it crosses it. MoveToEdge moves the window to the next edge within the usable area of the screen it is on. Pressed again with the window already against that edge of the screen, it moves the window to the neighbouring screen in that direction, against the near edge of that screen, unless the window is maximised, when it stays. With no screen in that direction, nothing moves. For Super+arrow across screens, see The transition.

Occupancy is an input

Whether a workspace is “occupied” is asked by two programs, and they mean different things:

Both rules are correct for their program, so libkwm implements neither. kwm_ws_adjacent() is told which workspaces are occupied and finds the nearest one in a direction. With wrapping on, it wraps at most once, so a set of empty workspaces ends the search instead of circling it forever; with wrapping off, it stops at the end of the list. It returns -1 when there is none. The compositor builds the occupancy list for the first 64 workspaces.

In the compositor this search backs the left-occupied and right-occupied targets of GoToDesktop and SendToDesktop. Every target of those two actions, plain or occupied, wraps from the last workspace to the first unless wrap="no" is given. The shipped rc.xml names four workspaces (main, www, hack, misc) and uses only the plain left and right targets, which step to the neighbouring workspace whether or not it is in use:

Keys Action
Super+Page_Up, Super+Page_Down GoToDesktop left, right
Super+comma, Super+period GoToDesktop left, right (wrap="yes" restates the default)
Super+Shift+comma, Super+Shift+period SendToDesktop left, right
Wheel over the bare desktop, with desktop_icons = no GoToDesktop left, right (a compositor default)

To skip empty workspaces, bind to="left-occupied" and to="right-occupied" instead.

What the model cannot express

Each of these is a deliberate narrowing. They are listed so that nobody spends time looking for the setting that would widen one.

Screen layout is an order, not a geometry

kdos-display, the screen-arrangement tool (Super+p), places screens edge to edge from x = 0, tops aligned, in list order. A vertical arrangement, an overlap or a deliberate gap cannot be expressed. See Decisions.

How a drag feels

A drag runs screen edges and window edges through the same kind of edge search, and near an edge it resists or attracts. That is interaction, and it stays in the compositor (src/desktop/kdos-comp/src/resistance.c). <resistance><screenEdgeStrength> in rc.xml (default 20) is the distance in pixels over which a screen edge holds a window before it goes over; <windowEdgeStrength> (default 20) is the same for window edges. A positive value resists entry, a negative one attracts, and 0 takes that kind of edge out of the search entirely, so the drag crosses freely. What the library shares is where an edge is, not how it feels to cross one.

The size a client accepted

A client may ignore the size it is configured with, as a terminal does when it keeps to whole character cells. The library hands back the rectangle that was asked for and has no notion of the one that was taken. A client that ignores its configure shows as content cut off at the frame’s edge. For resizing towards an edge, the compositor (src/desktop/kdos-comp/src/snap-constraints.c) remembers the size the client chose, so that a second GrowToEdge in the same direction goes past the edge the client stopped short of instead of aiming for it again.

Where a window was last time

Remembering a rectangle across a close and an open is a question about a program, which the library has no word for. Window memory answers it in the compositor.

A relation between windows

libkwm computes rectangles from the space available and the obstacles present; it has no notion of a neighbour. That is why ownership and tab groups are the compositor’s, and why there are no tile groups: two windows side by side that move, resize and minimise together.

A window between two places

Every rectangle in this chapter is final from the moment it is computed: a window is never halfway between two of them, and nothing here has a duration. With window_motion = yes in comp.conf the compositor draws a window on its way (rising into place as it opens, sinking as it closes or is minimised, sliding sideways with a workspace switch; see kdos-comp), but that is a picture over a state that has already changed. Placement, snapping, the edge search, window memory and occupancy all see the final rectangle and the final workspace. The one thing that follows the picture is the pointer, which meets a window where it is drawn.

Which workspace a window is on

A workspace is a set the compositor keeps, and “on every workspace” (ToggleOmnipresent, Super+o) is a flag on a window rather than a number. Nothing asks libkwm about it.

The library’s entry points

The library includes nothing but the C library’s <stdlib.h> and <limits.h>, calls no other KDOS library, and needs no maths library: the placement search compares doubles with plain arithmetic. That constraint matters because of how the compositor is built. kdos-comp’s build.sh compiles libkbase, libkcolor and libkwm into one static archive, libkdos.a, and hands it to meson through LDFLAGS; a library here that needed a real -l dependency would have to carry that dependency into the compositor’s meson build with it.

Every entry point in the library has a caller in a shipped program:

Entry point Called from
kwm_place() kdos-comp, src/desktop/kdos-comp/src/placement.c: the overlap search
kwm_tile_next() kdos-comp, src/desktop/kdos-comp/src/view.c: the tiled-state transition
kwm_tile_geom() kdos-comp, src/desktop/kdos-comp/src/view.c: the rectangle of a tiled state
kwm_edge_check() kdos-comp, src/desktop/kdos-comp/src/snap.c: the snap rule
kwm_clip_add(), kwm_clip_sub(), kwm_edge_best() kdos-comp, src/desktop/kdos-comp/include/edges.h: the edge-search arithmetic
kwm_ws_adjacent() kdos-comp, src/desktop/kdos-comp/src/workspaces.c: the nearest occupied workspace
kwm_drag_threshold() kdos-desk, the kdos-shell surface that draws the desktop icons (src/desktop/kdos-shell/desk.c): when a press on an icon becomes a drag

kwm_edge_between() and the inline helpers kwm_edge_is_cardinal() and kwm_edge_invert() have no caller outside the library. They are reached through kwm_edge_check() and kwm_tile_next(), and kwm_edge_between() is exported so that the contract’s btwn rows can test it directly. A rule kept in the library that nothing calls would be a second answer to a question the compositor already answers, and the contract cannot arbitrate between two copies when only one of them ships.

Window cycling (the window switcher: Super+Tab and Super+Shift+Tab in the shipped rc.xml, Alt+Tab and Alt+Shift+Tab from the compositor’s built-in bindings) is not in the library. kdos-comp keeps its cycle list as a wl_list and steps it by following links, with the list head as a sentinel so the ring always closes. An index-based library function cannot take a linked list without turning each step into a scan, and a copy of the rule that nothing calls could drift from the one that runs without anything noticing.

A drag is measured in cells. kwm_drag_threshold() reports a drag as soon as the pointer leaves the cell it went down in, because the desktop icons are laid out on a character-cell grid and every geometry there is in cells; a threshold in pixels would make the same gesture pick an icon up at one scale and only select it at another.

The contract

testing/fixtures/wm/geometry.txt is the contract between libkwm and the compositor. Each section of the file names the function its rows were derived from: a kdos-comp function for every kind except drag, whose only caller is kdos-shell and which cites kwm_drag_threshold() itself. testing/selftest.sh replays the file against libkwm (the replay is test_wm() in src/libs/selftest.c) rather than asserting anything of its own. To add a case, add a row under the section that names where the behaviour lives.

The file records what the compositor (and, for drag, the desktop icons) does, read off its source. A row that fails means libkwm and the compositor have drifted apart, and the named function is where to look. Where a rule in the library disagrees with a row, the row is right.

There are 106 rows in eight kinds, counted by the command below:

Kind Rows Covers
tile 28 The tiled-state transition
geom 26 The rectangle a tiled state occupies
wsadj 12 The nearest occupied workspace
place 10 The overlap search
btwn 9 Edge search: does an edge lie between here and the target
best 9 Edge search: the nearer of two candidate edges
drag 6 When a press-and-move has become a drag
clip 6 Edge search: saturating addition and subtraction

One place row (place unusable … -> @unchanged) states what the caller does rather than what the library returns: kwm_place() is never reached when the output is unusable. The replay counts that row without driving it, so it exercises 105 rows and accounts for all 106. Most place rows assert an exact position; one asserts only that the answer is not the default corner (@not-0,0), because where a crowded search lands depends on its iteration order and a hand-computed answer would test the arithmetic of whoever wrote the row.

The replay fails in two further cases. A row whose kind it does not recognise is an error rather than a silent skip, so a new kind cannot enter the file without a branch that drives it. And fewer than 80 rows means the file was not found or lost its rows.

Count the rows with:

grep -vE '^\s*(#|$)' testing/fixtures/wm/geometry.txt | awk '{print $1}' | sort | uniq -c

See also


Part III — Architecture, chapter 19. Previous: 18. The design language · Contents · Next: 20. The programs