Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

The one idea

Mission Control, the iOS app-switcher card stack, the iPadOS thumbnail grid, macOS dock hover-peek, and the iOS icon→window genie look like five features. They are one operation:

place a window’s texture somewhere other than its real geometry, and animate between the two

  • Overview — every window scaled onto a grid
  • App switcher — every window in a row, scrubable
  • Peek — one window scaled at a cursor position
  • Genie — one window interpolating between an icon rect and its own rect
  • Tiling / scrolling — the same, ending at a layout slot instead of a thumbnail

Implementing these separately is why compositors end up with modes that animate inconsistently. Solium builds the operation once and expresses every mode as a script over it. If a new mode needs new Rust, the transform layer is missing something — that is the test.

Layers

┌──────────────────────────────────────────────┐
│  Scripts: modes, layouts, gestures           │  Lua
├──────────────────────────────────────────────┤
│  Script API: enumerate, transform, bind      │  Rust
├──────────────────────────────────────────────┤
│  Panes: what the compositor calls a window   │  Rust
├──────────────────────────────────────────────┤
│  Presentation transform + animation clock    │  Rust
├──────────────────────────────────────────────┤
│  Render: Smithay's GlesRenderer (GLES2)      │  Rust
├──────────────────────────────────────────────┤
│  Smithay: protocols, input, backends         │  crate
└──────────────────────────────────────────────┘

Beside the stack, three crates hold the arithmetic and depend on nothing: crates/animation (curves and springs), crates/effects (deformations and fragment effects) and crates/layout (where windows go). Each is described below.

A window is a pane, not a client’s surface

The compositor does not work with smithay::desktop::Window directly. It works with a pane: an identity and a slot, whose content is a QML scene the compositor draws, a mapped client, or the remains of one on its way out.

The reason is that a window’s life should begin when the user asks for the application, not when the application’s client happens to connect. Everything that follows from that — the slot reserved immediately, the other windows moving aside, being able to close it while it is still loading, the application’s content simply appearing inside it — is impossible while the compositor’s idea of a window is the client’s, because that type requires a surface to exist.

A pane keeps its id and its slot across all three kinds of content. Adoption is the point: when a client belonging to a launch we started maps a toplevel, the toplevel becomes that pane’s content. Nothing is created, nothing is replaced, and nothing else in the compositor notices — a decoration keyed by pane id carries its animation straight through the handover.

Panes are how the compositor answers every interaction at once. The application catches up inside that answer; the compositor never waits for it first. Opening reserves the window’s slot, frame and all, before the program has even started. Resizing makes the rectangle being dragged the truth, and the client’s last picture fills it until the client redraws. Closing reflows the other windows the moment the close is asked for, while the window fades where it stood and the application decides whether it can go – and if it says no, a layout puts it back (#128, and docs/modes.md for the events). A feature that has to wait for a client before anything on screen responds is the one this design exists to rule out.

A client is matched to its pane by an activation token first and by walking up from its process id second. The token is what survives a launcher that forks and exits; the process walk is the fast path for everything that does not. A window whose pane is older than the launch’s is never matched by its token: that is an application keeping one instance, answering the launch with a window it already had. The launch’s pane dissolves instead, the window stays where it lives, and the scripts hear activate (#177, and docs/modes.md).

What a program Solium starts inherits is decided in launch.rs (#175). Its environment is the one Solium was started with: noted at the top of main (launch::remember), before Qt, EGL or any library they load writes into it, and taken whole rather than scrubbed name by name. Solium::spawn (state/open.rs) then sets only the session’s own variables: WAYLAND_DISPLAY; DISPLAY, naming Solium’s Xwayland or removed when there is none; XDG_CURRENT_DESKTOP, which is Lilium unless the session named one; XDG_SESSION_TYPE when it is unset; and the activation token, as XDG_ACTIVATION_TOKEN and DESKTOP_STARTUP_ID. In the child, every descriptor above stdio is marked close-on-exec by one close_range (close_on_exec_above_stdio), and the descriptors Xwayland and Qt’s eglfs would otherwise pass on are marked the same way (xwayland.rs, host.cpp). The compositor’s own Qt takes no input method from the session (qml::keep_input_methods_out removes QT_IM_MODULE and QT_IM_MODULES), while the programs it starts keep the user’s.

Space has not gone away and is not going to. It stays underneath as the authority on stacking and damage for a mapped client, because that bookkeeping is worth keeping and not worth rewriting. Panes is the view over it, and sync_panes is the single place the two are reconciled — so they cannot drift apart at any of the sites that map or unmap.

QML is not a special case bolted on here. A pane whose content is a scene is as ordinary as one whose content is a client, which is what makes a loading window, a placeholder for an application that died, and a surface the compositor draws for its own reasons one mechanism instead of three.

The design and the order the migration went in are recorded in docs/spikes/2026-09-06-window-provider.md.

Presentation transform

Every window carries a target rect, an opacity, a 4×4 matrix turned about a pivot, an optional deformation and a depth, which the renderer uses instead of its real geometry, plus an animation driving the current value toward the target. Rounded corners are not part of it: they belong to the pane style, and are drawn by a fragment program (below).

Rules:

  • Transforms never change real geometry. Leaving a mode restores the layout exactly, because the layout was never touched.
  • Input hit-testing follows the transform’s rectangle and opacity, or a window in overview is clickable where it was, not where it is drawn. The depth and the matrix are drawing only: a window raised over its neighbour, or tilted, is still clicked where its rectangle is, and one faded to nothing is not clicked at all.
  • One animation clock, ticked from the render loop. Not per window, not per subsystem, not per script.

The animation engine is a separate crate

crates/animation holds the curves, the springs and the timing, and depends on nothing — no Wayland, no renderer, no Solium. That is not tidiness:

An animation you can only judge by launching a compositor is an animation nobody tunes.

There will be many animations and many settings for them, so the engine has to be usable outside the thing it animates. dev/preview compiles it to WebAssembly and embeds it in a page that animates plain boxes through the scenarios the compositor has — opening, overview, the switcher, a drag, a maximise — with curve, duration and spring settings live.

That page stays a test harness rather than a mockup. Recreating the compositor’s chrome in it would be a second copy of the design with the same drift problem as a second copy of the curves, and motion is easier to judge without decoration.

The engine is called from that page rather than reimplemented in it. A copy of the curves in JavaScript would drift the first time either side changed, and the drift would be invisible because the page would still animate plausibly. The wasm and native answers are checked against each other instead of assumed.

The page owns the geometry — where a window starts and ends — because that is layout and belongs to the compositor and its scripts. The engine only ever answers “how far along?”, which is exactly the split inside the compositor: it reports progress, and the caller interpolates.

The compositor keeps only the part that needs a compositor: which rectangle a window is travelling between. Curve::from_name is what scripts bind to, so a curve added to the engine is immediately available to every mode and to the preview without touching the renderer.

So are the shapes

crates/effects is the same crate again for the other half of a transform: the vertex deformations a rectangle and a matrix cannot express — a genie, and whatever fold, curl or page turn comes next. Same empty [dependencies], same unit tests, same wasm preview with live sliders, and the same reason.

It has a second reason the animation engine does not, and it is the harder one: the damage tracker needs a deformed window’s bounding box before anything is drawn, and a shader cannot tell it one. So a vertex function stays CPU-side and parametric — a name and some numbers, never user code. A fragment effect could one day be code a style supplies, because a bad one is a wrong picture and a bad damage rect is a corrupt screen; today there is one, rounded corners, and its shader is compiled into the crate. See docs/design/2026-09-12-panes-and-effects-design.md.

The split with the compositor is the anchor. A deformation morphs between two rectangles, and the far one is named rather than given: deform = { effect = "genie", to = { window = id } } carries an identity, which Solium::aimed_at_for resolves on the frame that draws it, and to = { surface = name } aims at a sol.surface scene the same way: at its instance on the window’s own monitor, not on the one the pointer is on. Both ends are in global space and are moved onto each screen together, so a genie on a monitor that is not at the origin lands where it was aimed (#143). A rectangle read out of a Lua table when the binding was pressed aims at where a dock icon was half a second ago, which is the stale-copy failure the anchors planned in docs/shell-boundary.md are meant to rule out for a hosted dock. crates/effects never sees the identity — it has no idea what a pane is, which is what keeps it testable without a session.

Deform::from_name is what scripts bind to, exactly as Curve::from_name is, and script::shipped checks the shipped Lua against both.

Fragment programs and captures

A pane style can round the client itself, with client.radius in its Pane.qml. That is neither a transform nor QML: the client’s pixels are the application’s, so the compositor draws them through a fragment program of its own. crates/effects/src/fragment.rs says what an effect reads — nothing, each surface where it is, the node’s own pixels, or what is beneath it — and keeps the shaders as source text. Rounding reads each surface where it is: clip.rs wraps every surface of the client, in its own place, and draws it through a program (compiled in pass.rs, between frames) that takes the surface’s own coordinates into the client’s rectangle and cuts the corners there. So a rounded window costs no extra pass and no copy of the window; a subsurface is cut only where its corner is the client’s; the edge is one screen pixel wide at any zoom; and a window that is tilted or pulled by a genie keeps its corners, because its capture draws its client the same way.

A rounded window that fades in or out is faded surface by surface, with no picture of it taken: where a subsurface overlaps its parent (a player’s video over its window, say) the two show through each other while the window is translucent. An opaque window looks as it always did. A fullscreen window has no frame, so it is drawn square and its surfaces stay candidates for direct scanout; a maximised one keeps its corners.

A capture is what a warp draws: a target from the renderer’s pool (pool.rs), made once with its own framebuffer object and kept by the pane (keyed.rs) for as long as the window keeps its size. render::prepare builds every capture’s elements first, then binds a 1x1 carrier once and draws each capture into its target, a frame each, so nothing that draws a capture can run Qt.

A capture records what it was drawn from (each element’s id, commit, geometry, source, alpha and transform) and is drawn again only when that changes, keeping one id for life and moving its commit only when redrawn. So a still window’s capture costs no pass and no damage, and one whose client is painting pays a pass a commit. SOLIUM_RECAPTURE=always draws every capture on every pass, as before.

A warp, a pane’s capture drawn through a mesh, carries that capture’s id and a commit of its own, which moves when the capture is redrawn or the mesh’s shape changes (its rectangle, matrix, pivot, scale, or the target its deform is aimed at). So a window held still at a tilt is neither captured again nor damaged by its warp, and the warp is drawn once per damage rectangle, under a scissor, never whole: the whole mesh drawn over the copy already in the buffer would blend its translucent edge twice (dev/wirecheck case 11g).

A warped window’s popups are not in its capture. They are a capture of their own, at the rectangle they cover, which may reach past the window, and are drawn as a second warp in front of the window’s: the same matrix, pivot and deform, meshed over their part of the window’s rectangle, with an id and a commit of their own. So a menu stays whole and on top of its window’s frame during a tilt or a genie, is captured again when it commits, and gives its texture back when it closes, though the window goes on warping.

The texture is the cost. Every visible warped window keeps one, which is why render::prepare captures no pane that no monitor shows; a rounded window keeps none. What is beneath a node, the input a blur would need, is named in fragment.rs and nothing constructs it yet; rounded corners are the one effect there is.

The arrangements are a crate too

crates/layout is the third engine crate with nothing in [dependencies]: where each window goes, given how many there are and how much room. It holds master-stack, the tiling tree, the scrolling strip and the grid overview lays windows out on, and scripts reach them as sol.layout.master_stack, .tree, .scroller, .strip, .scrolling, .scroll_to and .grid. The preview page arranges its boxes with the same code.

So “modes are scripts” has a precise meaning. The Lua decides which arrangement, when, and for which windows, and keeps what it decided; the arithmetic of each arrangement is Rust, tested without a session, and no script has to derive it again.

And a transform names a selection

present::Frame says how one pane is drawn. group::Shift says how a named set of things is drawn — windows, sol.surface surfaces, whole monitors — and the two compose: a member is drawn at its own transform plus every selection it is in.

That is one mechanism rather than three. A whole-screen effect is a selection that is an output; a set of windows moving as one is a named set; and a workspace is its windows plus its own background, which is why the workspace slide stops leaving the wallpaper behind. Nothing in the compositor knows what a wallpaper is; it travels because workspaces.lua put it in the selection.

Two decisions worth knowing:

Membership lives on the group, not in a table keyed by PaneId beside the panes. A group owns its members and its own in-flight transform and both leave when it does, so a selection naming a window that has closed resolves to nothing and costs a u64. That is the same rule Pane follows for its frame, its timers and its capture buffer.

A node in no selection pays nothing. Shift::apply on an identity shift returns the frame it was given, and the first thing anything asks is whether any selection exists at all. A compositor that routed every window through new arithmetic to support a group nobody declared would have made every frame worse.

One stacking order, one hit test

stack.rs is the one list of what is drawn over what on a monitor, topmost first: overlay, then a fullscreen window when one covers the bars, then top, the windows, bottom and background. At each layer a client’s layer surfaces are over a script’s sol.surface scenes, because the client was installed on purpose. The renderer draws in that order and every hit test above or below the windows asks in it, so what is on top is what is clicked (#141, #142). fullscreen.covers = "none" keeps the bars over a fullscreen window.

A script’s scene is on top only where its own items take the point. When a hit test reaches a sol.surface band, the compositor asks that scene’s live item tree, which answers Hit::Nothing, Hit::Hover or Hit::Press (qml/hosted.rs). topmost_above passes what the event asks for: Asking::Hover for motion, Asking::Press for a button or the wheel. So a strip that takes only hover leaves presses to the window under it (#173). While a scene holds a press it took, or holds a Grab, no client has the pointer (surface_at in state/hit_test.rs), and a press anywhere is the grab’s to take or to be dismissed by (claim_under). A touch never lands on a scene (touch_under; see #181 under Form factors). docs/shell-boundary.md, “What a hosted shell is given”, has the behaviour.

Among the windows, one predicate says whether a window is under a point: owns, in state/hit_test.rs — on a screen that draws it, and inside what it paints there. Solium::window_under asks it from Rust and sol.window_at from Lua, and both look through what is left of a window whose client has gone.

The renderer is GLES, and the code says so. Scaling and cross-fading textures is unremarkable work that GLES2 does well, and Smithay has no Vulkan renderer to choose instead. Drawing does not stay behind Smithay’s generic Renderer and Frame traits, because three things it needs are not on them:

  • drawing a texture through four independent corners, for perspective and the genie (warp.rs, with its program and draw in raw GL in warp/gl.rs; the program is compiled once, between frames, in pass.rs, and if it does not compile a deformed window is drawn flat rather than not at all);
  • fragment programs of Solium’s own, for rounded corners (clip.rs, drawing each surface through the programs pass.rs compiles from the GLSL ES source kept in crates/effects);
  • the EGL context and fence that QML on the GPU shares with Qt (qml/paint.rs, surface.rs).

So the render elements are typed on GlesRenderer (render.rs). What stays free of any renderer is the arithmetic: present.rs and the animation, effects and layout crates depend on no renderer, and the effects crate carries the rounded-corner shader only as source text. A Vulkan backend would need its own element set, warp, pass programs and QML import, which makes it a port rather than a swap; the Vulkan spike lists the pieces. The one reason left to pay for that is compute shaders. Explicit sync (#59) is a protocol Smithay offers on GLES too, and Smithay’s multi-GPU renderer (#63) is built on GLES.

Hosted scenes

scripted.rs keeps what scripts declare with sol.surface, and builds one live instance of a surface on each monitor it is on, as the monitor is placed; an instance goes with its monitor. A surface declared again writes its changed properties into the live scene instead of rebuilding it (#161). surface.rs is one hosted scene: a QML file, the area it was placed in, and its pointer events. qml/hosted.rs is the compositor’s half of what a scene and the compositor say to each other: properties written in place, the monitor the instance is on, the models’ rows, pointer events, what the scene’s items claim at a point, its reserve, its grabs, its keyboard wants and the keys it is told, and the actions it sends with Solium.send, whose data json.rs reads. qml/keys.rs puts the compositor’s buttons, modifiers and keys in Qt’s terms.

state/hosted.rs reads what the scenes report, once a pass (settle_scenes), and applies it. A reserve goes into the work area (reserved_on, which work_area_on in state/monitors.rs adds to the layer-shell zones) and re-flows the layout once (#162). A Grab is held or dismissed (settle_grabs, dismiss_hosted_grab). The keyboard is held for a scene, its keys are delivered and repeated at the keymap’s rate, and it is given back (settle_keyboard, deliver_scene_key, repeat_scene_key, end_keyboard_hold) (#163). The actions the scenes sent go to the surface listeners, each in a dispatch of its own (settle_actions), and sol.act’s verbs become the commands that do them (act); state/commands.rs tells each attempt’s done once the dispatch that ran it is applied. docs/shell-boundary.md, “What a hosted shell is given”, has the behaviour.

Scripting

Lua, and modes really are scripts — lua/overview.lua is overview, and the compositor contains no code that knows what overview is. The proof is that the Rust that used to implement it was deleted, not wrapped.

The keyboard pill is the same claim for something drawn. lua/keyboard_indicator.lua is the policy and reads keyboard.indicator, which no Rust names. It draws with qml/Solium/KeyboardPill.qml, through KeyboardPillLayer (qml/Solium/KeyboardPillLayer.qml), which every shipped pane style adds, or on an overlay sol.surface (qml/indicator/keyboard.qml), and taking require("keyboard_indicator") out of init.lua takes the pill away. Its tests are scenarios: Lua files in crates/solium/tests/scenarios/, played by scenario.rs under cargo test. So a feature written as configuration is tested without Rust that knows about it.

The boundary is one module, script.rs, and it is shaped so a mode never learns a window is a Wayland surface:

  • Reads are a snapshot. Windows, work area and cursor are built fresh per dispatch and handed to Lua by value.
  • Writes are commands. sol.present queues; the compositor drains the queue after the handler returns. A script cannot mutate the compositor directly, so its idea of a window’s geometry cannot drift from the compositor’s — and no borrow of compositor state is alive while Lua runs, which is what stops a script re-entering the seat mid-dispatch and deadlocking.
  • Ids, not indices. A window’s id is stable for its lifetime and never reused, so a script holding one across frames cannot address a different window with it.
  • The compositor does not know what modes exist. A script names itself with sol.status. The compositor keeps and logs that name and draws nothing with it; a hosted shell reads it as Solium.status.

No compositor config key per mode. That is how a mode set becomes closed.

What the compositor publishes

The models scenes read are built from the compositor’s own state (models/), so nothing is mirrored. Each model is diffed by key (models/diff.rs) and sent to Qt as one batch, every row’s values written before any row is announced (qml/rows.cpp), once a frame, in render.rs just before qml::tick. Four exist today: the monitors, read as Solium.monitor and Monitors (models/monitors.rs); the windows, read as Windows (models/windows.rs); the workspaces Lua declares with sol.workspaces, read as Workspaces (models/workspaces.rs); and the keyboard, read as the Keyboard singleton (models/keyboard.rs).

text_input.rs answers zwp_text_input_v3 itself, not through Smithay’s module, which discards every request while no input method runs. It keeps only which field is live and its caret, and publishes that as sol.text_input() and sol.on("text_input") in the global space, and as caret in a pane’s own space; it draws nothing. keyboard_change.rs tells the configuration (sol.on("keyboard")) and the scenes (Keyboard.changed(what)) when the layout, Caps Lock or Num Lock really changes. It never fires for ordinary typing, and does not tell the configuration while the session is locked. Every decoration layer is also handed values from sol.pane_values, a general channel from Lua that the compositor does not interpret, and a layer whose root says dormant is not drawn, and in software lets go of its image (LayerScene::sleeps in decoration.rs).

Design rules

Carried from the Hyprland-fork post-mortem. Each one cost real time to learn.

One authority for any piece of state. Two models of window state — the compositor’s and a mirror in a helper process — produced a deadlock where a decoration was required to learn the geometry that decided whether to create a decoration.

Geometry the compositor animates against must arrive with the frame that shows it. Never a side channel. A dock icon rect sent whenever the shell chooses and applied whenever it arrives is a mirror, and mirrors drift. For a hosted dock the plan is that there is nothing to send: its QML names an icon, and the compositor reads where it is from the same engine after layout. If the dock is a layer-shell surface committing frames, its icon rects ride along with that commit and are applied atomically with it. Neither is built yet.

Commands are not state. focus_window is a verb; the resulting focus change comes back through the event stream, not as a reply.

A protocol carries one concern. When a name is hard to choose, the interface is doing too much.

In-process for anything window-coupled. Decorations, the animation engine, the bar and mode overlays run inside the compositor. Notifications, settings UI, launcher and media controls are ordinary clients — none of them touch window geometry.

One design system, one engine

Decided 2026-09-05. The compositor hosts a single QML engine, and every surface the desktop draws is a scene in it: window decorations, and a hosted shell’s bar, dock and launcher. The ones that are themed import one Solium.Theme singleton; the fallback pointer and the default wallpaper keep fixed colours.

The requirement that forces this is not “consistent styling” — it is that an object must be able to move from the dock into a titlebar. Two processes painting their own pixels cannot do that; the best available would be a fake. It is not built, and one engine does not make it a reparent: every scene has a QQuickWindow of its own, so each is its own scene graph. The plan is a flight or a morph, a third, live instance of the same component drawn over both ends while they are hidden; one engine is what lets that instance be the component itself. See docs/shell-boundary.md.

Chrome is QML, hosted in-process

Decided 2026-09-04. The bar and window decorations are authored in QML and rendered by Qt’s scene graph inside the compositor process — the model KWin uses for Aurorae. Out-of-process was tried in the Hyprland fork and measured at ~15 fps and 39% CPU, with the process boundary as the ceiling.

Concretely: a C ABI shim (crates/solium/qml/host.cpp), QQuickRenderControl with no visible window, and animations driven by the compositor’s clock through an animation driver the render loop advances. That last part is not a detail — QML animating off Qt’s own timer would drift against every window transform beside it, which is the same mistake as having two animation clocks. A QML Timer rides that clock too whenever anything else animates, so between frames the event loop that serves Qt advances it, rather than going around it. That is qml/wake.rs: Qt’s own poll set is one descriptor in the compositor’s event loop, which turns readable when Qt’s next timer is due or a descriptor Qt waits on is ready. Qt is then served on the compositor’s clock (qml::drain), and a scene that changed asks for one frame and no more.

Beside host.cpp are three files that register native types under the same Solium URI as the shipped QML module, so one import Solium reaches both: attached.cpp, the attached Solium object (Solium.monitor, Solium.input, Solium.keyboard, Solium.surface.reserve) and the Grab type; rows.cpp, the list model that applies the keyed row batches Rust sends; and keyboard.cpp, the Keyboard singleton. attached.h, rows.h and keyboard.h declare Q_OBJECT types, so build.rs runs Qt’s moc on them (MOC_HEADERS; QT_MOC names moc when it is not found). dev/wirecheck compiles the same files from its own build.rs, with its own copy of that list, so a new header that declares a Q_OBJECT type goes into both lists, and a new file beside host.cpp into both builds.

No Qt QPA plugin available here will adopt the compositor’s EGL context, so the GPU route runs the other way round: the compositor allocates a buffer through GBM and Qt’s OpenGL scene graph renders into it as an imported dmabuf. That is the default wherever a trial render in a child process passes at startup. Where it does not, Qt’s software scene graph renders into a memory buffer that is uploaded instead — chrome is small and only re-uploaded when Qt reports it changed. Qt fixes the choice for the life of the process, which is why it is made before Qt starts; dev/README.md, QML on the GPU, has how to force either one. See docs/spikes/2026-09-04-qml-in-compositor.md for the original measurement and the two Qt traps it hides.

A bar reserves its height whether it is a client or hosted. The work area is what every layer surface’s exclusive zone and every hosted surface’s reserve leave of a monitor, so windows are placed beside a bar, never under it. A hosted surface’s reserve is apart from its size: a whole-monitor shell reserves only its bar’s edge (docs/shell-boundary.md, “Room of its own”). And a fullscreen window covers the top layer, bars included, unless fullscreen.covers says otherwise.

Form factors

One compositor, one layout engine, different input profiles and default modes. Only the input profile is chosen today: SOLIUM_FORM_FACTOR sets click and touch focus, focus-follows-mouse, the drag modifier and natural scrolling (input/profile.rs). Every form factor starts floating, and there is no app switcher, no peek and no compositor gesture yet. Touch reaches a client’s window (checked with Firefox on a Surface Pro 7), but nothing the compositor draws reacts to it: frame buttons, a hosted shell’s scenes, overview and the screen edges (#181). Gestures are E7 (#7), after v0.1.0. The table is the intent, for E7:

Primary inputDefault mode, intended
Desktoppointer + keyboardfloating or tiling
Laptoptrackpad gesturestiling with overview
Tablettouchscrolling with app switcher
Phonetouchone window, switcher on gesture

The form factor selects defaults and an input profile. It does not fork the codebase, and it must not fork the animation engine.

Non-goals

  • Hyprland plugin compatibility. Abandoned deliberately. The aim is a compositor complete enough not to need plugins; a plugin protocol can come later on its own terms.
  • Reusing the Hyprland fork’s code. The ideas carried over; the code did not.
  • An out-of-process shell painting window decorations. Tried, measured, rejected: ~15 fps at 39% CPU after optimisation, and the process boundary was the ceiling.