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

Solium

Solium

Solium is the Wayland compositor of Lilium DE, written in Rust on Smithay and configured in Lua. Its window frames, its wallpaper and the desktop shell it hosts are QML running inside the compositor, in one engine, so the desktop can be one design that you change without rebuilding anything.

Captured by the compositor reading back its own framebuffer, in a nested session with the shipped configuration and nothing else, under the light theme Solium shipped until 2026-10-04 and not retaken yet: the shipped theme is now dark grey. The window on the left wears the default frame, a QML scene rendered in-process; the one on the right asked to draw its own and was let. The wallpaper is a QML file too. docs/modes.md has every mode, frame by frame.

Status

Alpha, and not yet anyone’s daily desktop. The first release, v0.1.0, will be a preview release, and it is not cut until the desktop is daily-drivable (the daily-drive label), the native preview shell is written (a bar at the bottom, a dock at the top, quick search, desktop icons, widgets with real data and a native lock screen), and there are packages: the point where it is ready for an open beta. docs/beta.md is that road. It is being readied for daily use, and the daily-driving trial that decides whether it is ready has not happened yet. What stands in the way is the daily-drive label, and the honest reasons are specific:

  • A hosted shell is not yet a whole desktop (#169): it has no icons yet, and the preview shell itself is not written.
  • Suspend and resume have never been tested (#64), and a session on the hardware sometimes starts with no input devices and stops itself (#48).
  • Nothing has run unattended for hours on the hardware (#65).
  • There is no explicit sync, so Vulkan and NVIDIA clients can stutter (#59).
  • There are no touchpad settings, so no tap-to-click (#157); no volume, brightness, media or screenshot keys by default (#151); and logind’s lock and sleep requests are ignored (#153).
  • There are no packages in a repository: a Fedora 44 package, or an install, is built from a checkout (#66).
  • Two tiling bugs found in use are fixed, and stay open until real use bears that out: a window drawing past its tile over its neighbours (#133), and a closing window keeping its tile until its client had gone (#128).

Short of that trial, it has been checked on the hardware at commit dfc95ce, on a desktop with an NVIDIA RTX 3070 and on a Microsoft Surface Pro 7 (Intel Ice Lake integrated graphics), both on Fedora 44. On both, QML renders on the GPU and its animations run, the screens go off when idle, swaylock locks, and the Caps Lock and layout pill works with the shipped configuration. On the Surface Pro 7, closing the lid turned the screen off, though Solium has no lid handling of its own. Touch reaches applications’ windows, but nothing Solium draws itself reacts to touch yet (#181).

And a bug in a compositor takes the session with it. The ones that get found are the ones real use finds, which is why the trial matters more than the test suite.

What works today

  • Layouts. Dwindle tiling, scrolling, floating and overview, each running per monitor, with a workspace per monitor or one for the whole desk. A minimum tile, with a new window overflowing to the next empty workspace (#134); applications’ own minimum and maximum sizes (#115); focus and move by direction, and floating and fullscreen toggles (#150); and a reload that keeps every layout as it was (#116, #118).
  • Windows. A window exists from the keypress that launches it, and shows a loading window until its application has drawn; an X11 application’s window does not yet replace its loading window (#180). Relaunching a single-instance application that is already running brings its existing window forward where it is, instead of moving it into a new slot (#177). Closing and quitting fade out (#126, #127); a resize follows the drag (#113); a modal dialog floats over its parent (#72); a drag shows its icon (#57); and one stacking order serves drawing and clicks, with a fullscreen window covering the bars (#141, #142) and getting its place back after (#92). X11 clients work through XWayland, and menus and tooltips are placed, grabbed and kept on screen.
  • Chrome. Eleven pane styles and per-corner rounding, QML on the GPU by default with a software fallback (#147), and cursor themes and the shapes clients ask for (#81, #24).
  • Shells. shell = { scene = … } hosts one in the compositor’s own QML engine, one instance on every monitor (or on the one shell = { on = … } names), each reading its own monitor live as Solium.monitor (#161). It takes the pointer only where its items take input, so the windows under it can still be clicked (#173). It gets every mouse button, the wheel and the modifiers, a popup holds the pointer with Grab, and an item that asks with Solium.keyboard gets the keyboard (#163). A bar reserves its edge, whatever the scene’s size, with reserve on sol.surface or Solium.surface.reserve from QML (#162). Any layer-shell client works too; bars and lockers both get the frame callbacks they rely on (#149), and a hosted scene’s timers fire on an idle desktop (#164).
  • Monitors. Several at once, each at its own refresh rate, arranged from the configuration or guessed; plugged in and unplugged while the session runs (#43); scaled, worked out from the panel or set. Screens go dark after ten minutes with nobody at the machine, and wlopm and swayidle can turn them off too (#54); the ten-minute screen-off has been seen working on both machines named above.
  • Input. Keyboard layouts, with bindings that keep working under a non-Latin one (#132) and on shifted keys (#121). Escape reaches applications: the overview binds it only while it is open (#174). A small pill near the text field’s caret says Caps Lock is on, or which layout you just switched to; it is written in Lua and QML as configuration rather than in the compositor, and set with keyboard.indicator (#178). On touch screens, touch reaches applications’ windows and a tap focuses the window under it, but nothing Solium draws itself answers touch yet (#181).
  • The lock and idle. The lock fails safe: the session is locked before the locker has drawn, and stays locked if the locker crashes. An idle inhibitor counts only while its window is on screen, and a browser’s D-Bus inhibitor holds the screens on too (#152).
  • The session. Started from the login screen, Solium tells systemd and D-Bus where its display is and starts graphical-session.target and XDG autostart, and stops them when it ends (#146). Programs Solium starts get the environment Solium itself was started with, not the settings Qt has written into it since, and inherit no descriptor beyond stdio (#175).
  • Protocols. xdg-shell, xdg-decoration, xdg-output, xdg-activation, xdg-dialog, wlr-layer-shell, wlr-screencopy, wlr-output-power-management, ext-session-lock, ext-idle-notify, idle-inhibit, cursor-shape, wp-viewporter, wp-fractional-scale, wp-presentation, wp-single-pixel-buffer, linux-dmabuf, relative-pointer, pointer-constraints, primary-selection, text-input-v3 and xwayland-shell. Both selections, copy and paste, cross the X11 boundary. text-input-v3 tells the compositor where the focused text field’s caret is; with no input method yet, no text is sent back (#26).

Not yet

No input method, so no IME and no on-screen keyboard (#26); no clipboard manager, which wants data-control (#52); no window rules (#56); no night light (#69); a virtual machine or a remote desktop cannot have Super (#58); and portal dialogs cannot be parented to the window that opened them (#67). Screenshots and recording come from wlr-screencopy, so grim and wf-recorder should work, but the portals, screen sharing included, have never been tested end to end (#83). Nothing the compositor draws reacts to touch yet (frame buttons, a hosted shell, the overview, window edges), though touch reaches applications’ windows (#181); touch gestures are the touch epic (#7) and come after v0.1.0. docs/gaps.md is everything not built yet, at length.

A first configuration

Everything is a file you write, and none of it needs the compositor rebuilt. ~/.config/solium/user.lua holds only what you want changed, and is merged over the defaults:

return {
    pane = "left",
}

Press super+shift+r and every open window is framed again, with its titlebar down the left side. solium --check says, without starting anything, whether the configuration would load, which bindings it made, which settings it sets that nothing reads, with what you probably meant, and whether every scene it declares at load and every pane style of your own builds without an error or a QML warning. A reload whose configuration fails to load keeps the session as it was, and says why in the log: ~/.local/state/solium/session.log for a session from the login screen or --tty, and the terminal for a nested one.

docs/ricing.md goes on from there, and Every setting lists what there is to set. Your own QML in ~/.config/solium/qml shadows what ships, file by file. A single Solium/Theme.qml there is meant to restyle every frame without copying the rest; until #88 is fixed, the shipped one still wins.

How it is built

Five ideas carry most of the design. Each is a choice with a cost, and the cost is named.

Chrome is QML, inside the compositor

Titlebars, the wallpaper, every pane style and the pointer Solium draws when no cursor theme is set are QML, rendered in-process through QQuickRenderControl and drawn as ordinary render elements. Not a shell talking to a compositor over a protocol — the same process, the same frame. A shell is hosted in that same engine, as QML written against Solium’s own API, through shell = { scene = ... } in the configuration: see docs/shell-boundary.md. Quickshell support was removed (#172).

That is why a titlebar can be reloaded while the session runs, why a hosted shell can read the same theme as the window frames, and why a decoration can be a gradient, a shader or a clock without the compositor learning what any of those are. It is also why Qt is a hard dependency and why the compositor drives Qt by hand. There is no Qt event loop: each frame advances Qt’s animations on the compositor’s clock, and between frames the compositor’s own event loop serves Qt’s timers and the descriptors Qt waits on, on that same clock, so a Timer fires on an idle desktop and costs a frame only when it changes something.

On the hardware, QML renders on the GPU: Qt draws into a buffer the compositor allocated, once a trial render in a short-lived child process has shown that works on this machine. Where it does not, QML renders in software and the log says why. solium --tty --qml gpu or --qml software (or SOLIUM_QML=) forces one, and the last log line beginning QML renderer: says which a session got — see dev/README.md.

A pane owns a window and its chrome

A pane is a window plus the layers drawn around it. Layers have a depth — behind, frame, above — and a bleed, which is how far past the window’s own rectangle a layer may paint. The client is drawn between them, which is the thing a single decoration file cannot express: a glow behind, a titlebar in front, and the window in the middle of its own frame.

A style is a folder under qml/panes/, so a pane style is a bundle you can copy, not a setting you can only choose from.

Everything a window does is one transform

Position, size, opacity, depth and rotation pivot are one Frame per window per moment, and animation is interpolation between two of them. A layout does not move windows; it says where they should be, and the transform layer gets them there. Which is why every mode animates identically and why a thumbnail in overview carries its own titlebar — the frame is part of the window’s transform, not a thing drawn beside it.

The policy is Lua, the arithmetic is a crate

Tiling, scrolling, floating and overview are Lua scripts: when to split, where a new window goes, what a key does. The arithmetic they ask for — the dwindle tree and the scrolling strip — is crates/layout, which knows nothing of windows and also arranges the boxes in dev/preview’s animation page. sol.present hands a script the same transform the compositor uses, so a layout somebody writes is not a second-class one. Bindings and the modes themselves live in Lua too, and --check will tell you what actually loaded — because a configuration that fails to parse at startup leaves a compositor with no layouts and no bindings at all, and that failure should cost a log line rather than a session.

Effects are fragment programs with declared inputs

An effect says what it needs — nothing, or its own pixels — and the compositor arranges the passes. What is behind it is the third input, the one a blur needs, and it is declared and not built yet. Rounded corners are a shader over the window’s own texture, which is why they apply to the pane rather than to the client, and why a corner radius can differ per corner so a titlebar and a window can meet more than one way.

Drawing is GLES2, through Smithay’s GlesRenderer, and the code says so rather than hiding behind Smithay’s generic renderer traits: a window drawn through four corners, the rounded-corner shader and QML on the GPU each need GLES or EGL, which those traits do not offer.

And the parts that are deliberately not ours

Solium does not draw a bar or own a notification of its own: it hosts the shell’s QML as configuration, and layer-shell clients beside it. It tells the session where its display is and leaves starting the rest to systemd. It blanks its screens after ten minutes alone and no other idle policy is its own: it reports idleness and lets a policy daemon act on the rest; it exposes wlr-screencopy and lets a portal record. docs/shell-boundary.md is where that line is drawn and defended.

Why this stack

Smithay is a Wayland compositor library, not a compositor. It hands over protocol plumbing, input and backends while leaving layout, rendering and policy to us — which is where a desktop environment’s character actually lives. niri is Rust-on-Smithay with working touch and gesture support, so the multi-form-factor path has a reference implementation rather than being a bet. Rust because a crash in a compositor takes the session with it, and memory safety removes an entire class of them.

GLES2, because Smithay has no Vulkan renderer — its backend::vulkan is device enumeration only — and niri, the reference implementation this stack was chosen for, uses GLES. The price is that a Vulkan backend would be a port, not a swap: the render elements are typed on GlesRenderer, the four-corner warp calls GL directly, rounded corners are a GLSL ES program, and QML on the GPU shares its buffers through EGL. The spike lists what each would need.

Documentation

All of it is also a website, https://lilium-linux.github.io/solium/, with reference pages made from the code.

ForRead
using itdocs/ricing.md, configuring it — start here; Every setting and the configuration reference, the shipped Key bindings, and Flags and environment
scripting it in Luadocs/modes.md, desktop modes and how to write one; docs/animation.md, the animation engine; the Lua API
styling it in QMLdocs/decorations.md, window frames and everything else drawn in QML; the pane styles’ README, the contract a style is written against
writing a shelldocs/shell-boundary.md, what a hosted shell is given and what belongs to the compositor
working on itCONTRIBUTING.md, how to send a change and the rules it is judged by; docs/architecture.md, how it is built and why; dev/README.md, the build, the gate and the instruments it is tested with; SECURITY.md; THIRD_PARTY.md, what came from other projects
where it standsdocs/status.md, with the roadmap, everything not built yet and the road to a public preview
why it is the way it isthe decision records in docs/spikes/ and docs/design/, each with the evidence that settled it

Thanks to

  • Smithay — Solium is a Smithay compositor, and most of what is hard about being one is Smithay’s work rather than this project’s: DRM, GBM, libinput, the seat, the protocol implementations. Its example compositor is also the first place to look when something here does not make sense.
  • niri — the reference for how a serious Smithay compositor is actually put together, and the answer to more than one “surely this cannot be the way” while reading DRM code. Its scrolling layout is why lua/scrolling.lua exists to be compared against.
  • Hyprland — where this started. Solium began as a Hyprland fork, and that work is archived: it proved the decoration and window-control ideas, and its post-mortem — including why an out-of-process QML shell was the wrong architecture — is in the lilium-de repository. The ideas carried over and none of the code did, but the case that a compositor is allowed to be beautiful and configurable at the same time was made there first.
  • Quickshell — the demonstration that a desktop shell can be QML all the way down. Solium hosts QML inside the compositor rather than beside it, which is a different answer to the same question, and it is a different answer because Quickshell had already shown what the question was.

License

Solium is free software under the GNU General Public License, version 3 only (GPL-3.0-only).

The logo, logotype and default wallpaper are © 2026 Illia Kotomin, licensed CC BY-SA 4.0; see docs/brand.

Contributions are welcome; see CONTRIBUTING.md. Each contributor agrees once to the Contributor License Agreement; the pull request template carries the one sentence that does it. What came from other projects is listed, with its licence, in THIRD_PARTY.md.

Getting started

Building Solium, running it, installing it, and a first configuration. The first three sections are the README’s; the last two are the start of Ricing Solium, which goes on from there.

Building

Solium needs Rust 1.88 or newer (edition 2024), a C++17 compiler, Qt 6.5 or newer (Qt Quick and Qml), and the development files for Wayland, libinput, libudev, libseat, xkbcommon, GBM, EGL and libdrm. Lua is compiled in and needs nothing installed. The build also runs Qt’s moc on the headers of Solium’s own QML types; QT_MOC names it when the build cannot find it, and dev/README.md says where it looks.

Rust is easiest from rustup; a distribution’s own Rust works too if it is new enough. The system packages:

Fedora

sudo dnf install gcc gcc-c++ pkgconf-pkg-config wayland-devel libinput-devel \
    systemd-devel libseat-devel libxkbcommon-devel mesa-libgbm-devel \
    mesa-libEGL-devel libdrm-devel qt6-qtbase-devel qt6-qtdeclarative-devel

Arch Linux

sudo pacman -S --needed base-devel wayland libinput systemd-libs seatd \
    libxkbcommon mesa libdrm qt6-base qt6-declarative

Debian 13 and Ubuntu 24.10 or newer (older releases ship a Qt older than 6.5)

sudo apt install build-essential pkg-config libwayland-dev libinput-dev \
    libudev-dev libseat-dev libxkbcommon-dev libgbm-dev libegl-dev libdrm-dev \
    qt6-base-dev qt6-declarative-dev

openSUSE Tumbleweed

sudo zypper install gcc-c++ pkgconf qt6-base-devel qt6-declarative-devel \
    'pkgconfig(wayland-server)' 'pkgconfig(libinput)' 'pkgconfig(libudev)' \
    'pkgconfig(libseat)' 'pkgconfig(xkbcommon)' 'pkgconfig(gbm)' 'pkgconfig(egl)' \
    'pkgconfig(libdrm)'

Then, from the checkout:

cargo build            # target/debug/solium; add --release for an optimised build

Or in a container, on Fedora without the development packages. The image has the C toolchain and the libraries, and your own rustup install is mounted into it. It is an image of Fedora, and what it builds links that release’s libraries, so build it for the release you run: FEDORA_VERSION (44 by default) is a build argument of dev/Containerfile. On any other distribution, install the packages above instead. A release whose Qt is older than 6.5 cannot run Solium at all; a pull request from one still gets every check except the GPU one from CI.

podman build -t solium-build:fc44 -f dev/Containerfile dev/
SOLIUM_GATE_IMAGE=localhost/solium-build:fc44 dev/gate.sh

dev/gate.sh builds and runs every check; dev/README.md has the plain podman run for a build on its own, and CONTRIBUTING.md has the rest of what a change needs.

Running it

From a free TTY (Ctrl+Alt+F3), and not from inside a running desktop session — --tty takes the screen and will fight whatever else has it:

cargo build
./target/debug/solium --tty

Nested, as a window inside an existing session, for development:

./target/debug/solium

Once installed (the Fedora package or dev/install.sh, below), the command is solium: from a text console run solium --tty, and solium --probe, which is safe inside a running desktop, first shows what the hardware offers.

Three flags worth knowing:

--checkwould the configuration load? which bindings survived? do its scenes and your styles build?
--probeevery connector and every mode it offers, without taking the screen
--debug-modeadds the Developer Tweaks panel, on super+shift+d

solium --help lists every flag and the variables that change a session, and a flag Solium does not know is refused rather than started with.

super+shift+q ends the session and super+shift+r reloads the configuration without ending it. The rest of the bindings come from --check, because they belong to a script rather than to the compositor, and Key bindings lists every one that ships.

On the hardware, anything that goes wrong is also written to ~/.local/state/solium/session.log, synchronously — that log exists for the case where the screen is gone and the power button is the only way out.

Install

On Fedora 44, either build a package of the checkout and install it with dnf, or install from the checkout into ~/.local. The login screen offers Solium after either. Use one: with both, it lists Solium twice.

A Fedora package, a development snapshot of the commit checked out:

podman build -t solium-build:fc44 -f dev/Containerfile dev/   # once
dev/rpm.sh                                                     # prints the dnf line
sudo dnf install ./target/rpm/RPMS/x86_64/solium-0.0.0~git*.rpm

dev/rpm.sh builds the release binary in the build container, packages it with this machine’s rpmbuild (sudo dnf install rpm-build), checks the package unpacked, and prints the dnf line. It installs under /usr, so there is no other step, and sudo dnf remove solium takes it out. It is built for Fedora 44: copy the .rpm to another Fedora 44 machine and install it the same way, with no Rust or build image there. Another release needs a package built for it. dev/README.md has the details.

From a checkout, on Fedora 44 (or a Fedora with the same Qt):

podman build -t solium-build:fc44 -f dev/Containerfile dev/   # once
dev/install.sh
sudo install -Dm644 ~/.local/share/solium/solium.desktop /usr/local/share/wayland-sessions/solium.desktop

dev/install.sh builds a release binary in the build container, copies it and the shipped QML and Lua into ~/.local (--prefix picks another place), runs the installed copy’s --check, and prints the sudo line above — the one step that needs root, which it leaves to you. Then log out and pick Solium from the session list.

Getting back. The login screen remembers the last session, so from then on it offers Solium first: pick Plasma to return. Inside Solium, Ctrl+Alt+Backspace ends the session whatever your configuration says (unless the screen is locked), and so does super+shift+q in the shipped configuration. Ctrl+Alt+F3 switches to a text console, where you can log in and read ~/.local/state/solium/session.log. Solium handles both chords itself, so neither helps if Solium itself hangs.

What it installs is a copy: rebuilding or checking out another branch does not change it, and running dev/install.sh again replaces it. When the login screen’s session file is from an older install, it says so, and the sudo line has to be run again. It refuses while a Solium session is running from it, and while ~/.local/share/solium is a link, which it would otherwise delete through.

The rest of the session. It also puts solium-session.target and solium-autostart.target in ~/.config/systemd/user, and lilium-portals.conf in ~/.config/xdg-desktop-portal. The session file starts solium-session, which starts Solium as the session: Solium tells systemd and D-Bus where its display is and starts those targets, which start graphical-session.target and XDG autostart, and they are stopped again when it ends, a crash included. A Solium that has stopped answering ends itself five seconds after logout asks it to (session.stop_timeout), so that it is cleaned up after too. One Solium session runs per user at a time. Portals, the programs in ~/.config/autostart, and user services written for a graphical session then find Solium. A file there that you have edited, or linked, is left alone. The portal file sends screen capture to xdg-desktop-portal-wlr, which Fedora packages separately (sudo dnf install xdg-desktop-portal-wlr). The Fedora package puts the units in /usr/lib/systemd/user and the portal file in /usr/share/xdg-desktop-portal instead, and recommends xdg-desktop-portal-wlr, so dnf installs it; it also recommends foot, so super+return always has a terminal to open. A polkit agent, a keyring, applets and other separate programs start through XDG autostart or a user unit with PartOf=graphical-session.target. docs/shell-boundary.md says how, with the one command that starts Fedora’s KDE polkit agent under Solium. session.systemd = false in your configuration turns all of it off.

To remove it, dev/install.sh --uninstall, then the sudo rm -f /usr/local/share/wayland-sessions/solium.desktop it prints. Your ~/.config/solium and the session log stay.

Still to come (#66): COPR, so that dnf installs and updates Solium without a checkout, and an Arch PKGBUILD. dev/README.md has the details.

The thirty-second version

Two files, and the difference matters. user.lua is settings: a table of what you want changed, merged over the defaults, and everything you do not mention keeps shipping. Bindings are settings too — there is a bindings section, so adding a key does not cost you the second file. init.lua is the entry point — writing one replaces the shipped configuration entirely, layouts and modes included, and is what you want only when you are rewriting the session rather than adjusting it.

Almost everything on this page is user.lua.

Write ~/.config/solium/user.lua with only what you want changed:

return {
    gap = 4,
    pane = "reactive",
    tiling = { split = 0.618 },
}

It is merged over the defaults, key by key, through nested tables — so tiling = { split = ... } keeps the animations underneath it. Lists are replaced whole, because a list of column widths with one entry changed is a different list, not a longer one.

Every setting is the list of everything you can put in there, one row per section, and the configuration reference explains each key. Both are made from config.lua, the file that sets the defaults, so they cannot drift from it. Key bindings is every key that ships, and what it does.

Three guides go deeper than the recipes below:

modes.mdwhat a desktop mode is, and how to write one
animation.mdthe animation engine, curves, and where the feel lives
decorations.mdwindow frames, the loading window, the pointer

Where things live

whatwhere
your settings, bindings included~/.config/solium/user.lua
a whole session of your own~/.config/solium/init.lua — replaces the shipped entry point
one module, replaced~/.config/solium/tiling.lua, scrolling.lua, …
your pane styles~/.config/solium/qml/panes/<name>/
your loading window~/.config/solium/qml/loading/*.qml
your pointerany QML file, named by cursor.scene (below)
your colours and fonts~/.config/solium/qml/Solium/Theme.qml, once #88 is fixed
the shipped files, to copy fromcrates/solium/qml and crates/solium/lua in a checkout; share/solium/qml and share/solium/lua under the prefix of an install: /usr for the Fedora package, ~/.local for dev/install.sh
the log~/.local/state/solium/session.log, for a session started with solium --tty or from the login screen. A nested run logs to the terminal it was started from

~/.config is $XDG_CONFIG_HOME when that is set, and ~/.local/state is $XDG_STATE_HOME.

For the files you write, your directory is searched first in every case but your colours. A file you write shadows the one that ships, and everything you did not write still comes from the shipped set — including its later improvements. Your own Theme.qml does not shadow the shipped one yet: the shipped Solium module is found first (#88). Don’t edit the shipped files in place: an install replaces them, and user.lua does the same job without being overwritten.

Ricing Solium

Everything below is a file you write. Nothing here needs the compositor rebuilt, and nothing needs you to copy the files that ship in order to change one thing in them.

Two commands are worth knowing first:

solium --check          # would my configuration run? which bindings survived? do its scenes build?
super+shift+r           # read it again, in the running session

--check is the one that saves afternoons. A configuration that fails to load is reported with the file and the line, and the running session keeps whatever it already had — so a typo costs a line of output rather than your windows.

It also reports the quieter failure: a setting the defaults do not define. tilling = { split = 0.6 } is perfectly good Lua and merges in perfectly cleanly, and then nothing ever reads it — so --check names it, suggests what you probably meant, and exits non-zero.

  2 unrecognised setting(s) -- written, merged, and read by nothing:
    open.scal                   did you mean open.scale?
    tilling                     did you mean tiling?

Then it builds every scene the configuration declares when it loads (the wallpaper, the shell, any sol.surface of your own) on one monitor, draws each once, and loads every pane style in your own qml/panes/, every layer included, with the pane style and loading scene SOLIUM_PANE and SOLIUM_LOADING name when they are set. A scene that fails to build, misses a required property or prints a QML warning while it is built fails the check too. It builds them in software, so a style that needs the GPU is named and not built, and a scene declared later, by a handler, is not seen.

Three things it cannot see, so that its silence means something: the contents of a list (monitors entries are replaced whole and not descended into), the keys inside keyboard, cursor and bindings — the first two are checked against a list, the third accepts anything you can press — and a value of the wrong type, since gap = "12" is a recognised key.

The thirty-second version

Two files, and the difference matters. user.lua is settings: a table of what you want changed, merged over the defaults, and everything you do not mention keeps shipping. Bindings are settings too — there is a bindings section, so adding a key does not cost you the second file. init.lua is the entry point — writing one replaces the shipped configuration entirely, layouts and modes included, and is what you want only when you are rewriting the session rather than adjusting it.

Almost everything on this page is user.lua.

Write ~/.config/solium/user.lua with only what you want changed:

return {
    gap = 4,
    pane = "reactive",
    tiling = { split = 0.618 },
}

It is merged over the defaults, key by key, through nested tables — so tiling = { split = ... } keeps the animations underneath it. Lists are replaced whole, because a list of column widths with one entry changed is a different list, not a longer one.

Every setting is the list of everything you can put in there, one row per section, and the configuration reference explains each key. Both are made from config.lua, the file that sets the defaults, so they cannot drift from it. Key bindings is every key that ships, and what it does.

Three guides go deeper than the recipes below:

modes.mdwhat a desktop mode is, and how to write one
animation.mdthe animation engine, curves, and where the feel lives
decorations.mdwindow frames, the loading window, the pointer

Where things live

whatwhere
your settings, bindings included~/.config/solium/user.lua
a whole session of your own~/.config/solium/init.lua — replaces the shipped entry point
one module, replaced~/.config/solium/tiling.lua, scrolling.lua, …
your pane styles~/.config/solium/qml/panes/<name>/
your loading window~/.config/solium/qml/loading/*.qml
your pointerany QML file, named by cursor.scene (below)
your colours and fonts~/.config/solium/qml/Solium/Theme.qml, once #88 is fixed
the shipped files, to copy fromcrates/solium/qml and crates/solium/lua in a checkout; share/solium/qml and share/solium/lua under the prefix of an install: /usr for the Fedora package, ~/.local for dev/install.sh
the log~/.local/state/solium/session.log, for a session started with solium --tty or from the login screen. A nested run logs to the terminal it was started from

~/.config is $XDG_CONFIG_HOME when that is set, and ~/.local/state is $XDG_STATE_HOME.

For the files you write, your directory is searched first in every case but your colours. A file you write shadows the one that ships, and everything you did not write still comes from the shipped set — including its later improvements. Your own Theme.qml does not shadow the shipped one yet: the shipped Solium module is found first (#88). Don’t edit the shipped files in place: an install replaces them, and user.lua does the same job without being overwritten.

Recipes

A different frame

return { pane = "left" }

Eleven styles ship, and the pane styles’ README lists them with what each one is for. Reload and every open window is re-framed.

No frame at all

return { pane = "none" }

No bar, no border, and no QML scene built per window — which is different from a decoration that draws nothing: there is no scene to rasterise, so an undecorated desktop costs nothing per window. For a tiling layout whose own bar makes a titlebar redundant, or for taste. SOLIUM_PANE=none does it for one run.

Your own frame

Copy one you like into ~/.config/solium/qml/panes/ and edit it. A folder named top there shadows the shipped top, so you can keep using pane = "top" and mean yours. crates/solium/qml/panes/README.md is the contract: what a style declares, what each layer is told, what it can ask for, and what it costs.

A style is a folder holding a Pane.qml — what the frame reserves, and a list of layers, each its own QML scene at its own depth: behind the client, in the frame, or above it. A layer can also bleed past the window’s edge, which is how a border waves or a shadow reaches. A single QML file still works too and is still called a decoration; it lives in ~/.config/solium/qml/decorations/ and is one layer in the frame.

A layer is also told where the focused text field’s caret is, as caret, in the pane’s own space; whatever the configuration hands every pane with sol.pane_values{ key = value }, as values, which a delegated layer declares as property var values: ({}); and it can read the keyboard’s layout and locks as the Keyboard singleton. That is how KeyboardPillLayer {} draws the keyboard pill: the panes README’s What the compositor sets on every layer has each of them.

Rounded corners

// Pane.qml
client.radius: 12

A style may round the client’s own corners. The compositor cuts them as it draws the client: each of the window’s surfaces goes through a fragment program where it already is, so rounding buys no pass at all and no copy of the window, and a window that is tilted or pulled by a genie keeps its corners. client.radius: 0 is no effect at all rather than a radius of nothing, so a style that does not want it draws its client exactly as before, and nine of the eleven shipped bundles do not want it.

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; a maximised one keeps its corners.

pane = "rounded" is one that does, and is there to be looked at. Two things in it are worth copying. The radius is in logical pixels — the compositor multiplies by the monitor’s scale, so the corner is the same size on a HiDPI screen rather than half of it. And every layer of the style is told the number as clientRadius, so a bar or a border can hug the same curve:

// Frame.qml
property int clientRadius: 0   // written by the compositor
radius: clientRadius           // or clientRadius + 2, to hug it from outside

That split is the whole design: the compositor rounds the client, because those pixels belong to the application and only a shader can mask them, and QML rounds itself, because Rectangle.radius is free. A style that wants a rounded border around a square client sets only its own radius, and its client is drawn as an unstyled one is.

One corner at a time

Each corner can be given its own radius. Each of the four defaults to radius, so one key still means all four and nothing written before these existed changes:

key
client.radiusall four corners, and the default the four below fall back to
client.radiusTopLeftthat corner alone
client.radiusTopRight
client.radiusBottomLeft
client.radiusBottomRight
// Pane.qml — square on top, round underneath
client.radius: 12
client.radiusTopLeft: 0
client.radiusTopRight: 0

A 0 here has to be written out. Squaring a corner is half of what these keys are for, so a corner that is absent follows radius and a corner that says 0 is square — leaving the key out is not a way of squaring it.

Every layer is told all four by name as well:

// Frame.qml
property int clientRadiusTopLeft: 0      // and TopRight, BottomLeft, BottomRight
property int clientRadius: 0             // still written: the LARGEST of the four

clientRadius survives and is the largest of the four, because what a layer does with one number is hug the window from outside — and a hug has to clear the biggest cut or it crosses the curve. A layer that cares about one corner reads that corner.

Two ways a titlebar meets a rounded window

The shipped pair, and the reason the corners are separable at all. Both reserve a band at the top; what differs is which corners the shader cuts and where the bar is drawn.

pane = "rounded"pane = "flush"
the client’s top cornersroundedsquare
what draws the window’s topthe bar’s own radiusthe bar’s own radius
the bar’s heightinsetTop + clientRadiusinsetTop
the bar’s depthbehindframe
where the two meetthe bar shows through the client’s cut cornersa flat seam, corner to corner

flush is the simpler of the two: with the client’s top squared there is nothing to fill, so the bar stops at the seam and stays in the frame, where the compositor copies it band by band.

rounded cuts all four, which leaves two notches inside the window where the client’s top corners were. Something has to be behind them or they show the wallpaper — so its bar is clientRadius taller than its band and sits at depth: "behind". Under the client it fills the notches and covers nothing; over the client the same rectangle would eat the client’s top clientRadius rows, which in a terminal is the top half of the first line.

What a window shows before its application exists

A window’s life starts when you ask for the application, not when the program gets around to connecting — it takes its place in the layout immediately, the other windows move aside, and the application appears inside it. What is in it meanwhile is yours:

return {
    loading = {
        scene = "window",       -- qml/loading/window.qml, or a path
        patience = 8000,        -- give up on it after this long, in ms
        reserves_a_slot = true, -- take a place in the layout straight away
        decorated = false,      -- draw the titlebar while it waits
        fade = 180,             -- how long the scene takes to dissolve, in ms
    },
}

reserves_a_slot = false is the quieter reading: the other windows only move aside once the application is really there. decorated = true gives the waiting window a titlebar, and therefore a close button for an application that is not coming.

fade is the dissolve as the application appears underneath. It is the compositor’s, not the scene’s: whether a window is see-through is a presentation transform, like where it is and how big, so the compositor applies it to the scene’s picture as it draws it (animation.md). Set fade = 0 to cut straight to the application.

Your own scene goes in ~/.config/solium/qml/loading/. It is handed the program’s name and how long it has waited; the one that ships uses only the name, on the grounds that the window already is the window and the one fact you do not otherwise have is which application you are waiting for.

SOLIUM_LOADING=mine        # ~/.config/solium/qml/loading/mine.qml
SOLIUM_LOADING=~/mine.qml  # anywhere

Your wallpaper

wallpaper = "~/Pictures/whatever.png",

~ is expanded, the image is cropped to fill rather than fitted, and wallpaper = false turns it off — which is what you want if you run swaybg, hyprpaper, or a shell that draws its own. A layer surface on the background layer is drawn over this one, so leaving both on means paying to rasterise a picture nobody sees.

A list gives each workspace its own, and it travels with that workspace:

wallpaper = { "~/Pictures/one.png", "~/Pictures/two.png" },

workspaces.lua puts each one in the same selection as its desk’s windows, so one animation carries both and the background stops being left behind when you switch. Fewer pictures than workspaces cycles. It costs a scene per desk on every monitor, built when it is declared, and one screen-sized rasterisation per desk you have actually visited, per monitor — which is why one image stays one static surface: every desk sharing a picture would make a wallpaper that slides pixel-identical to one that does not.

The more interesting part is that there is no wallpaper in the compositor. lua/wallpaper.lua calls one thing:

sol.surface("wallpaper", {
    scene = "wallpaper.qml",
    layer = "background",
    on    = "every-monitor",
    properties = { source = "wallpaper/solium.png" },
})

sol.surface draws any QML scene at any layer on any monitor, so a bar, a dock, a heads-up display or a debug overlay is the same call with a different layer and a different on:

sol.surface("clock", {
    scene = "clock.qml",
    layer = "top",
    on    = { x = 40, y = 40, w = 420, h = 90 },
    properties = { format = "HH:mm" },
})

layer is background, bottom, top or overlay, and each sits under the matching wlr-layer-shell layer — so a real bar covers a scripted one, and swaybg covers this wallpaper. on takes every-monitor, primary, a connector name, or a rect in the global space. Each monitor it is on gets a scene of its own, built when the surface is declared or the monitor arrives and dropped when the monitor goes, and inside it Solium.monitor is that monitor. sol.surface(name, false) removes one.

Declaring the same name again with the same scene writes the changed properties into the live scene rather than building it again, so an open popup or a running animation in it survives. A key left out of properties keeps the value the scene last had, and a dotted key such as ["panel.open"] = true reaches a grouped property. Only a different scene file builds it again.

interactive = true lets the pointer reach it, where its items take input: a MouseArea, a control such as a Button, a pointer handler other than HoverHandler, or an item marked Solium.input: true (shell-boundary.md has the whole list). Everywhere else the pointer goes to what is under it (state::tests::real_client::reflow_on_close::hosted::a_press_where_the_shell_draws_nothing_reaches_the_window_under_it). A surface without it holds neither a Grab nor the keyboard (state::tests::real_client::reflow_on_close::hosted::a_surface_the_pointer_does_not_reach_holds_no_grab, state::tests::real_client::reflow_on_close::hosted::a_surface_the_pointer_does_not_reach_holds_no_keyboard). The scene sends an action, with data if it has any, Solium.send("pressed", { id: 3 }), and whoever is listening is told:

sol.surface("panel", { scene = "panel.qml", layer = "overlay",
                       on = area, interactive = true })

sol.on("surface", function(surface, action, data)
    if surface == "panel" then
        sol.log("pressed " .. action)
    end
end)

An action of the compositor’s own vocabulary, windows.focus with { id: model.id } say, is done for you, by lua/actions.lua through sol.act (shell-boundary.md has the list).

That is the whole of how the Developer Tweaks panel works, and it is entirely in lua/tweaks.lua — the compositor has no idea what a tweak is.

reserve = { bottom = 48 } takes those edges out of the work area of every monitor the surface is on, whatever its size, so a bar scene drawn across the whole monitor reserves only its strip and the windows are placed above it. The scene can say it too, Solium.surface.reserve.bottom: 48, which wins for each edge it sets; shell-boundary.md, “Room of its own”, has how the windows re-flow when it changes (state::tests::real_client::reflow_on_close::hosted::a_declared_reserve_takes_its_edge_out_of_the_work_area).

Place a rect on the monitors event, not at the top of your script. Scripts load before the screens are known — on the hardware backend, before the GPU is even opened — so a rect computed at load time is computed against zeros. The event fires once when the monitors are first known, again on every hotplug and again on every reload, which is when a placement needs redoing anyway. A surface whose on is "every-monitor", "primary" or a connector name needs no event: declare it at the top of the script, and the compositor builds its scene on each monitor it names as that monitor arrives, as wallpaper.lua and shell.lua do. Only a rect computed from the monitors waits for them:

sol.on("monitors", function()
    local area = sol.monitor()   -- the active monitor's work area
    sol.surface("panel", { scene = "panel.qml", layer = "top",
                           on = { x = area.x, y = area.y, w = area.w, h = 40 } })
end)

sol.monitors() lists every monitor, with its name, its work area, its whole rect and whether it is primary, for a panel on a particular one.

Copy qml/wallpaper.qml to ~/.config/solium/qml/wallpaper.qml and the background becomes whatever QML can be:

import QtQuick

Item {
    required property string source

    Rectangle {
        anchors.fill: parent
        gradient: Gradient {
            GradientStop { position: 0.0; color: "#12002e" }
            GradientStop { position: 1.0; color: "#7c4dff" }
        }
    }
}

super+shift+r reloads it without ending the session. source is handed in whatever the configuration said, and a scene that ignores it — like the one above — is perfectly valid.

Your keyboard

keyboard = {
    layout  = "us,ua",
    options = "grp:alt_shift_toggle",
},

The first layout is the one you start in. super+shift+k cycles them and so does Alt+Shift, because grp: options are implemented inside the keymap and work without the compositor being involved — the binding exists so that the feature is discoverable from solium --check rather than from knowing xkb.

Bindings follow the key, not the layout. With a Cyrillic layout active, super+q is still the key us has Q on, and so is super+shift+k. A binding you write in the other alphabet, such as super+Cyrillic_shorti, gets that key first. A key the active layout already labels in ASCII keeps its own name: Russian puts . where us has /, so on Russian that key is super+period.

variant takes one per layout in the same order, blank for plain: layout = "us,ua", variant = ",dvorak" is ordinary US and Ukrainian Dvorak. options also carries compose:ralt, which is the nearest thing to an input method until #26 lands.

Leaving a name out means “whatever the session already said”: an empty name makes xkbcommon read XKB_DEFAULT_LAYOUT and its siblings, which is where a display manager puts it. So a machine already configured elsewhere keeps working, and writing it here takes precedence.

The repeat rate is the compositor’s own — no environment variable reaches it, and before this it could not be changed at all:

keyboard = {
    repeat_rate  = 40,   -- repeats per second. 25 by default
    repeat_delay = 350,  -- milliseconds before repeating starts. 200
},

The locks can be set too, and Num Lock on at login is the usual one:

keyboard = { num = true },

That turns Num Lock on when the session starts, and again at every super+shift+r. caps works the same way for Caps Lock. Each is set by pressing the keymap’s own key inside the compositor, so the application with the keyboard is told and the layout stays where it was.

sol.keyboard() reads all of it back to Lua — the layout names, which is active and its names (layout_name, Russian; layout_short, RU), whether Caps Lock and Num Lock are on, and the repeat settings — and sol.keyboard{ caps = false } sets a lock. sol.on("keyboard", function(state, changed) end) hears the layout or a lock change, however it changed, and never ordinary typing, nor what is pressed at the lock screen. QML reads the same as the Keyboard singleton, in a hosted scene and a pane style alike. sol.on("text_input", function(field, why) end) hears the focused text field: why is "field" when one is enabled or focused, "caret" when its caret moves and "framed" when its window goes fullscreen or comes back. A caret that moves twice before the next frame is told once, where it ended up.

The keyboard pill

Switch layout, or press Caps Lock, and a small capsule appears just below where you are typing: ⇪ while Caps Lock is on, EN or RU for a moment after a switch, after the one macOS shows. It is drawn in the theme’s accent with its glyph in accentInk, which in the shipped theme is a light grey capsule with a dark glyph and a soft shadow: it stands out on a dark window and on a light one.

It is not a compositor feature. It is the shipped configuration’s example of doing something with what the compositor publishes, and it is two ways of doing the same thing, so you can see both:

  • Inside the window (show = "pane", the default). Every shipped pane style ends with one line, KeyboardPillLayer {}, a layer above the client that draws the pill at the pane’s caret — where the focused text field’s caret is, in the pane’s own space. Because it is part of the pane, it moves, scales and fades with its window. Your own style gets it by adding the same line. A window drawn with no frame — fullscreen, one that draws its own decorations, or every window when pane = "none" — has no pane style around it, so it gets the surface below at its caret instead: sol.text_input() says which, as framed.
  • On a surface of its own (show = "surface"): an overlay sol.surface that lua/keyboard_indicator.lua places at the caret in the global space, from sol.text_input(), and moves again each time sol.on("text_input") says the caret moved.

The policy is the Lua file: it listens to sol.on("keyboard") and sol.on("text_input") and decides what shows and where. An application that says where its caret is only after a key, as kitty does, gets the pill on its screen for the first key and at its caret a few milliseconds later, when it says. Kitty says so only as it handles each key, before the shell has echoed it, so a pill that stays while you type, Caps Lock’s, can sit a cell behind the cursor until the next key. The look is QML, KeyboardPill in import Solium, on Theme. The compositor provides the data underneath — text-input-v3 for the caret, the keyboard’s state and its events — and knows nothing about pills. These are its defaults, from config.lua; any of them goes under keyboard in user.lua:

keyboard = { indicator = {
    show = "pane",            -- "pane", "surface", or false
    on = { layout = true, caps = true, num = false },
    caps_on_focus = true,     -- Caps' pill again when a field is focused with Caps on (needs on.caps)
    fallback = "surface",     -- with no caret: on the screen, or false
    position = "bottom",      -- where on the screen: "bottom", "center" or "top"
    duration = 1200,          -- ms a layout's pill stays
} }

The caret comes only from applications that say where it is through text-input-v3; for the others, fallback = "surface" shows the pill on the focused window’s monitor instead. To try it nested, SOLIUM_KEY_AT presses Caps Lock and a layout switch for you (dev/README.md). To change the policy, copy lua/keyboard_indicator.lua next to your configuration, where it is found first. To change the on-screen pill’s look, copy qml/indicator/keyboard.qml to ~/.config/solium/qml/indicator/; a pane style of your own can draw a pill of its own at caret in place of KeyboardPillLayer {}. KeyboardPill itself is in the shipped Solium module, which a copy cannot replace yet (#88). To have none, write keyboard = { indicator = false } in user.lua; a whole init.lua of your own can instead leave out require("keyboard_indicator").

Your monitors

The compositor cannot work out which screen is on the left. The kernel reports connectors in an order that has nothing to do with your desk, so it guesses — every connected screen driven, left to right in that order — and about half the time the guess is wrong.

You almost never want coordinates. Say what is beside what:

return {
    monitors = {
        { name = "DP-1", mode = "2560x1440@260", vrr = true, primary = true },
        { name = "DP-2", mode = "2560x1440@75", right_of = "DP-1", align = "end" },
        { name = "DP-3", above = "DP-1", transform = "90" },
        { name = "HDMI-A-1", enabled = false },
    },
}

solium --probe prints the connector names this machine has, without taking the screen from whatever is drawing on it. A name nothing answers to gets a line in the log rather than being ignored — a monitor arrangement that silently does nothing is the hardest kind to debug, and the usual cause is a name that does not exist here.

key
right_of, left_of, above, belowbeside another monitor, by name. A chain resolves whatever order you write the list in, and no monitor in it needs coordinates
align"start", "centre" (default) or "end" — how the other axis lines up against something taller or wider
x, ythe top-left corner outright, if you would rather
mode"2560x1440@165". The refresh is optional; so is the whole thing — see below
vrrvariable refresh rate, where the monitor and driver offer it
transform"90", "180", "270", "normal", or the same with flipped-
enabled = falsedo not drive it
primary = truewhere a layer surface that named no output goes, and a hosted shell or sol.surface declared with on = "primary"
scaledevice pixels per logical one. Left out, worked out from the panel

super+shift+r applies the placement keys (right_of, left_of, above, below, align, x, y), scale, primary and enabled without ending the session. mode, vrr and transform are read when a monitor is first lit, so on one that is already lit they wait until it is next plugged in — or until a reload with enabled = false and another with it back.

Refresh rate lives in mode. "2560x1440@165", the way every display tool on Linux writes one. Drop the @ part and you get the fastest mode at that resolution; drop mode entirely and you get the fastest mode at the preferred resolution, which is almost always what you want. Three words also work:

"best"highest refresh at the preferred resolution — the default
"preferred"exactly what the monitor’s EDID says, refresh included
"widest"the largest resolution, fastest at that size

"best" is not "preferred", and the difference is the reason it is the default: the EDID’s preferred flag names a resolution and usually pairs it with a pedestrian 60 Hz. A 260 Hz panel reports 2560x1440@60 as preferred. Taking that literally drives a fast display slowly and makes every animation in the compositor look worse than it is. "preferred" is there for a monitor that is unstable at its fastest rate, which is a real thing and not something the automatic choice can know about.

A mode the monitor does not have warns and falls back rather than going black. solium --probe lists every mode each monitor offers, in exactly the form mode takes — which is the whole reason to run it:

  DP-1         connected, 36 modes, best 2560x1440@260, 600x340mm, 108 dpi, scale 1
                 2560x1440@260, 240, 200, 165, 144, 120, 100, 60  (preferred)
                 1920x1080@240, 120, 60, 50

scale is worked out for you, and you can override it. Left out, it comes from the panel’s own size: 2x above 192 dpi and 1x below, which is the number GNOME and KDE both use — matching them matters more than being right in the abstract, because it is what every monitor’s marketing and every forum answer is implicitly calibrated against. That puts a 13“ 4K laptop at 2x and a 27“ 4K at 1x, and the second of those is genuinely a matter of taste, which is why it is settable. Fractional values work.

solium --probe prints the dpi it measured and the scale it would choose, per monitor, at the end of each monitor’s first line above. That is the one number you need to decide whether to disagree. A monitor that reports no physical size says so there, and gets scale 1.

Scaling is not a zoom. Everything the compositor draws itself — titlebars, the loading window, the pointer — is rasterised at the monitor’s pixel count rather than drawn small and stretched, so a 2x screen gets twice the detail and not twice the blur. Your QML keeps working unchanged: it is laid out in logical units, so titlebarHeight: 32 is 32 logical pixels on every monitor and is drawn with as many real pixels as that monitor has.

vrr is off unless asked for. FreeSync, G-Sync compatible, Adaptive-Sync — the display’s refresh follows what is being drawn instead of the other way round, which is what removes tearing and stutter on anything that cannot hold a steady frame rate. It is not on by default because it interacts badly with some panels at low frame rates — visible flicker — and that is not something to turn on for somebody. The log says whether the connector actually offered it.

Three more are worth a sentence.

align is not cosmetic. A 1080p beside a 1440p leaves 360 rows belonging to no screen at all, and which end of the small monitor that dead strip is at decides where the pointer catches on its way past. "centre" halves the strip instead of putting all of it at one end, which is why it is the default; "end" lines the bottom edges up, which is what you want if both monitors stand on the same desk.

enabled = false frees a CRTC, not just the screen. A CRTC is the hardware that scans a buffer out, there are usually four, and a connector without one stays dark — so switching off a monitor you are not using is how you drive a fourth on a card with three.

A rotated monitor’s work area is portrait. transform changes the logical size, so every layout follows it without knowing anything about rotation, and the display pipeline does the turning.

Plugging in and unplugging work while the session runs. A monitor that arrives is driven with its entry above, as it would have been at startup, and the monitors event fires so anything placed per screen can be placed again. One that goes takes no windows with it: a window left on no screen is moved onto one that remains, and the layouts run again. dev/README.md has how to try it without reaching behind the desk.

Turning screens off

The screens go dark on their own after ten minutes with nobody at the machine, with no daemon to install. It is one number:

return { idle = { screens_off_after = 300 } }   -- seconds; 0 never does it

Ten minutes and not GNOME’s five, because GNOME dims first and Solium does not: here the first you know of it is the screen going dark. Anything that plays – a film, a call, a presentation – holds an idle inhibitor, and while that window is on screen the timer waits. When it lets go the ten minutes start again from then, so a film ending an hour in does not take the screen with it, and the next one in a playlist does not start in the dark. Nothing holds it off behind the lock screen, which goes dark like anything else.

Browsers ask over D-Bus instead. Chrome and Firefox keep the screen on during a film by calling org.freedesktop.ScreenSaver.Inhibit on the session bus, not through the Wayland protocol, so Solium owns that name while it runs, and what they hold counts like a window’s inhibitor: the timer waits, and so do swayidle’s timeouts. It goes when the browser lets go, closes or crashes, and holds nothing behind the lock screen. Firefox asks org.freedesktop.ScreenSaver first for a tab in view, before the portal. A program that asks the portal’s Inhibit reaches the same place through xdg-desktop-portal-gtk, which lilium-portals.conf gives Inhibit to, as long as no GNOME session runs on the same bus; KDE’s backend sends it to Plasma’s power manager, which is not there, so do not give Inhibit to kde. If another desktop already owns the name on the same bus – one running on another VT – it keeps it: Solium says so in its log and carries on without it. Nested, the name is owned only on the bus SOLIUM_SESSION_BUS names. To leave it alone altogether:

return { idle = { dbus_inhibit = false } }

Any key, click, scroll, touch or pointer motion turns every screen back on, however they went off, and that input is delivered as usual rather than swallowed. The case that decides it is typing a password at a lock screen that went dark: the first key is part of the password, and a compositor that ate it would fail the unlock for a reason nobody could see. A key release does not wake anything, so a binding that turns the screens off leaves them off when you let go of it.

A monitor that is off is not gone. It keeps its place in the arrangement, its work area and its windows; nothing is moved, no client is told a screen went away, and sol.monitors() still lists it, with power = "off". That is the difference from enabled = false, which takes a monitor out and frees its CRTC.

From a binding – none ships, since which key does this is a matter of taste:

sol.bind("super+F12", function() sol.monitor_power("all", "off") end)
sol.bind("super+shift+F12", function() sol.monitor_power("DP-2", "off") end)

Or leave it to swayidle. Solium speaks wlr-output-power-management, so wlopm works, and swayidle drives it without needing swaymsg. Turn the built-in one off so the two do not both do it:

return { idle = { screens_off_after = 0 } }
swayidle -w \
    timeout 300 'swaylock -f' \
    timeout 600 'wlopm --off \*' resume 'wlopm --on \*' \
    before-sleep 'swaylock -f'

wlopm on its own lists the monitors and whether each is on.

A window on a screen that is off is still told it may draw, once a second (idle.off_frame_interval, in milliseconds). Not telling it at all would freeze any program that waits for that inside its swap for as long as the screen is dark, which with the idle blank is all night. 0 stops them altogether, which is what sway does. A screenshot of a screen that is off is refused rather than left waiting.

Nested, there is no display to power off: a monitor that is “off” is drawn black and then not drawn at all, so all of the above can be tried in a window.

One workspace per screen, or one for the desk

Each monitor has its own workspace in view by default: super+2 switches the screen the pointer is on and leaves the other showing what it was, so a reference on the second monitor stays put while you move around on the first.

One line makes a workspace a whole desk instead, so a switch moves every screen together:

return { workspaces = { per_monitor = false } }

Neither is more correct — the difference is whether you think of your monitors as two screens or as one surface you happen to have cut in half.

Bars, docks and wallpapers

A shell — bar, dock and launcher together — can be hosted inside the compositor, in the same QML engine as the window frames, by naming its root file:

return { shell = { scene = "~/.config/solium/shell/shell.qml" } }

One scene on every monitor, or on the one shell.on names, written against Solium’s own QML API; shell-boundary.md has how to install one and what it is and is not given.

Two more settings are the shell’s. shell.outside_click is what a press outside one of its open popups does after closing it: "swallow", the default, or "pass", which also clicks what is under it. A table names popups by their Grab’s name: { default = "swallow", ["tray-menu"] = "pass" }. shell.keyboard.bindings is which compositor bindings still work while one of its fields holds the keyboard: "except_claimed" (the default: all but the keys the field claims), "all", or "none". The Ctrl+Alt escapes always work. A scene of your own drawn with sol.surface takes the same two, as outside_click and keyboard = { bindings = ... }. shell-boundary.md has Grab and Solium.keyboard.

A bar can also be an ordinary client over wlr-layer-shell, which means any panel already written for that protocol works. A surface names the output it wants and the compositor honours it, so a bar on every screen is one surface per screen, each reserving from that monitor’s work area. One that names no output gets the primary monitor.

A fullscreen window in front of the workspace its monitor is showing covers the top layer – a client’s bar, and one declared with sol.surface – and takes the clicks where the bar was, while the overlay layer (notifications, an OSD, a launcher) stays over it. It goes over the bars as it starts to grow and back under them once it has finished shrinking. To keep the bars over fullscreen windows:

return { fullscreen = { covers = "none" } }

See shell-boundary.md for why hosting is the design, and for both ways a shell attaches.

The preview shell

A fresh install is not a blank desktop. shell.scene above defaults to the compositor’s own preview shell – a bar along the bottom, built the same way any other shell is, on the public API shell-boundary.md describes and nothing else. It shows:

  • the workspaces of this monitor, as page dots – the current one an accent pill, an occupied one filled – and a click switches;

  • the running windows of this monitor’s current workspace, as chips – icon, title or app id, the focused one highlighted – and a click focuses one;

  • the keyboard layout, once more than one is configured;

  • a clock;

  • a top dock: pinned apps, then a hairline, then running ones that are not pinned, each a theme icon with a dot under a running one. Autohidden by default – nothing is reserved, and it slides in when the pointer reaches the top edge, out a little after it leaves. A click on an app that is not running launches it (Solium.Apps, 03 §3.2.13); on one that is, focuses its most recently used window.

    return { preview = { dock = {
        pinned = { "org.mozilla.firefox", "kitty" }, -- desktop ids, left unset
                                                      -- the dock picks a
                                                      -- terminal, a file
                                                      -- manager and a browser
                                                      -- from what is installed
        visibility = "always",                       -- or "autohide" (default)
        icon_size = 48,                               -- default 40
    } } }
    
  • quick search, super+d: a centred panel with a field, the live keyboard layout beside it, and results grouped into applications (matched by name and generic name), open windows (title or app id, with the workspace) and a few compositor commands that already exist – Overview, Tile windows, Fullscreen window, Reload configuration, Quit Solium. Arrow keys move the selection, Return activates it, Escape or a click outside closes.

    return { preview = { search = {
        key = "super+alt+space", -- default "super+d"
    } } }
    

    Cut from this first version (lua/preview/search.lua and qml/preview/Search.qml both say why, in full): the detail pane, a row’s own actions, Alt+digits, Ctrl+Return, the empty-query suggestions (there is no usage history yet to rank by), app keywords (the plain array this searches, sol.apps(), does not carry them – a real gap, not a corner cut), the settings and desktop-file sources, the container transform from the bar’s own search button (the bar has no search button yet either), and the “us,ru” layout-correction pass – it needs the compositor’s own keymap, which nothing here exposes yet.

  • desktop icons: Solium.dirs.desktop ($XDG_DESKTOP_DIR, or user-dirs.dirs; nothing drawn, and nothing scanned, when it is unset or is $HOME), on their own surface – layer = "bottom", below ordinary windows, the same as a real desktop, and below the bar above – 96x100 cells with 48 px icons, column by column from the top right, folders first then by name. A click selects (Ctrl or Shift adds), a double-click opens: a regular file through its MIME type’s default application, a .desktop launcher through its own Exec= once trusted, asked for once in a small card. Live through inotify: a file that appears or disappears on the desktop updates the icons at once, no reload needed.

    return { preview = { desktop = {
        from = "top-left",                   -- or "top-right" (default)
        cell = { width = 110, height = 112 }, -- default 96x100
        labels = 1,                          -- label lines, default 2
        open = "single",                     -- or "double" (default)
        show_hidden = true,                  -- default false
    } } }
    

    Cut from this first version (crates/solium/src/folder.rs and qml/preview/Desktop.qml both say why, in full): dragging and saved positions (there is nowhere durable to keep them yet), the right-click menus, a rubber band, showing the desktop (super+ctrl+d needs a window-presenting pass this compositor does not have), Open With and thumbnails, MIME detection by content rather than by extension, and the arrow-key/Return navigation the design note asks for (nothing yet gives the desktop the keyboard on demand, so claiming it unconditionally would only fight the dock and quick search for focus).

Next pieces – the island, quick settings, a tray, and previews – need services this compositor does not have yet, and are not here. lua/preview/ and qml/preview/ are where all of it lives, so copying one file still changes one behaviour, exactly as Your own frame and Bars, docks and wallpapers above do it.

Two ways to turn it off, or replace it:

return { preview = false }

turns the whole preview shell off and leaves a blank desktop, as before it existed. Naming your own shell.scene – or SOLIUM_SHELL_SCENE for one run – replaces it outright: the preview shell only ever fills in a default nothing else set, and never overrides either one.

Your own pointer

The pointer can be a QML scene of yours, named the way a shell is:

return { cursor = { scene = "~/.config/solium/cursor/Cursor.qml", size = 32 } }

A path, ~ expanded, or a bare name looked for in ~/.config/solium/qml/ and then in the shipped QML (cursor::theme::tests::a_configured_scene_is_found_as_the_shells_is). SOLIUM_QML_CURSOR=<file> is the scene for one run, over the setting (cursor::theme::tests::the_environment_overrides_the_configured_scene). It draws every shape a window or the compositor asks for, ahead of any XCursor theme, and is told which one (models::pointer::tests::a_configured_scene_hears_every_named_shape). A window that sets a cursor of its own, or hides the pointer as a game does, still does over its own surface (cursor::tests::a_pointer_a_client_hides_stays_hidden_with_a_scene_configured). super+shift+r applies a change and builds the scene again when its files changed. A scene that does not load leaves the theme, and then Solium’s own arrow, to draw the pointer, says so in the log, and is tried again at the next reload (cursor::tests::a_reload_swaps_the_scene).

It reads Solium.cursor. shape is the shape asked for, by its CSS cursor name: default over a titlebar, text over a text field, ew-resize on a window’s left or right edge, and so on (models::pointer::tests::a_named_shape_reaches_solium_cursor_shape). pressed is whether a button is held, velocity how fast the pointer moved over the last frame in logical pixels a second along x and y, zero once it stops, scale the scale of the monitor it is on and size the configured cursor.size (models::pointer::tests::the_published_pointer_is_its_buttons_its_motion_its_monitor_and_its_size). It says one thing back, Solium.cursor.hotspot on its root: the point of its picture that sits on the pointer, its top-left corner unless it says. Its size is its root’s own width and height, up to 256 logical pixels a side, so a glow or a shadow can reach past size with the hotspot still on the point the pointer is at; a root that sets no size is size square (cursor::scene::tests::the_scene_is_drawn_at_its_own_size_with_its_hotspot_on_the_pointer).

An arrow, an I-beam over text, and a glow that breathes:

// ~/.config/solium/cursor/Cursor.qml
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import Solium

Item {
    id: root
    readonly property int pad: 10        // room for the glow
    readonly property int size: Solium.cursor.size
    readonly property bool text: Solium.cursor.shape === "text"
    width: size + 2 * pad
    height: size + 2 * pad
    // The arrow points with its tip, the I-beam with its middle.
    Solium.cursor.hotspot: text ? Qt.point(pad + size / 2, pad + size / 2)
                                : Qt.point(pad, pad)

    // One breath, forever: the glow's strength and a tint in the arrow.
    property real breath: 0.25
    SequentialAnimation on breath {
        loops: Animation.Infinite
        NumberAnimation { to: 1; duration: 800; easing.type: Easing.InOutSine }
        NumberAnimation { to: 0.25; duration: 800; easing.type: Easing.InOutSine }
    }

    MultiEffect {                        // the glow, behind the drawing
        source: drawing
        anchors.fill: drawing
        shadowEnabled: true
        shadowColor: "#7aa2ff"
        shadowBlur: 1.0
        shadowOpacity: root.breath
    }

    Item {
        id: drawing
        x: root.pad
        y: root.pad
        width: root.size
        height: root.size
        readonly property real u: width / 24

        Shape {                          // the arrow
            anchors.fill: parent
            visible: !root.text
            ShapePath {
                fillColor: Qt.tint("white", Qt.rgba(0.48, 0.64, 1, 0.45 * root.breath))
                strokeColor: "#1c1c1e"
                strokeWidth: 1.6 * drawing.u
                startX: 0.8 * drawing.u; startY: 0.8 * drawing.u
                PathLine { x: 0.8 * drawing.u;  y: 16.3 * drawing.u }
                PathLine { x: 5.0 * drawing.u;  y: 12.5 * drawing.u }
                PathLine { x: 7.9 * drawing.u;  y: 19.1 * drawing.u }
                PathLine { x: 11.0 * drawing.u; y: 17.7 * drawing.u }
                PathLine { x: 8.2 * drawing.u;  y: 11.3 * drawing.u }
                PathLine { x: 13.7 * drawing.u; y: 10.9 * drawing.u }
                PathLine { x: 0.8 * drawing.u;  y: 0.8 * drawing.u }
            }
        }

        Rectangle {                      // the I-beam
            visible: root.text
            anchors.centerIn: parent
            width: 3 * drawing.u
            height: 16 * drawing.u
            color: "white"
            border.color: "#1c1c1e"
        }
    }
}

It animates on the compositor’s clock: a running animation asks for the next frame, and a scene with nothing running asks for none (cursor::scene::tests::an_animating_scene_asks_for_the_next_frame_only_while_it_animates). This one breathes forever: while the pointer is shown it is drawn on every refresh, and while it is not (a window hiding it or drawing its own, or the screens off) the breath is still stepped a frame apart, every 16 ms (qml::wake::tests::a_timer_beside_an_undrawn_animation_fires_with_no_frame_drawn). To let it rest, bind the animation’s running: to something the scene can see, such as Solium.cursor.pressed, the shape, or the velocity.

MultiEffect and ShaderEffect draw on the GPU path, which a session on the hardware uses by default (dev/wirecheck’s case 11 draws a pointer scene’s glow there). On the software path, which a nested session uses and qml.renderer = "software" chooses, they draw nothing, and the drawing under the glow is the whole pointer: that is why the breath is in the arrow’s tint as well. The picture is offered to the hardware cursor plane, as a theme’s is (cursor::scene::tests::a_scene_with_no_material_is_a_cursor_plane_element), and goes there when its size in device pixels fits the plane, commonly 64 or 128 pixels a side; there moving the mouse redraws nothing else. A larger one, such as this one at size = 32 on a 2x monitor (104 pixels a side), is drawn with everything else where the plane is smaller. Solium.region and Solium.material are accepted, and materials are off until #199: Solium.materialState reads "off" (qml::pointer::tests::a_region_and_a_material_are_accepted_and_materials_are_off).

Your own colours

Solium.Theme (crates/solium/qml/Solium/Theme.qml) is what every shipped frame, the loading window and the keyboard pill are drawn with, and a hosted shell that imports Solium can read it too. The shipped one is a dark theme of greys only, with no hue in it at all, the maintainer’s choice for now: near-black bars, light grey text, grey buttons, and an accent that is a light grey too (decorations.md lists every name). A shell that reads Theme.accent for its highlights gets that grey. The fallback pointer and the default wallpaper do not read it: their colours are fixed. A copy of Theme.qml in ~/.config/solium/qml/Solium/ is meant to restyle the frames, the loading window and such a shell at once, and does not work yet: the shipped module is found first (#88). Until then, a frame of your own (above) can carry colours of its own.

Your own animation feel

Named curves — linear, outCubic, outBack, inOutQuad, inOutCubic, spring — or four numbers, which are a cubic bezier’s control points:

return {
    tiling = { motion = { duration = 220, easing = { 0.34, 1.56, 0.64, 1 } } },
}

Those are the same four numbers CSS calls cubic-bezier and every easing generator on the internet hands out, so a feel you found elsewhere transfers directly. y may leave 0..1 — that is what overshoot is.

Going fullscreen and maximising are timed the same way, each on its own, and both glide by default: the window grows from where it is to cover its monitor, or its work area, and shrinks back. animate = false makes one instant and animate = true is the shipped motion again, and instant names the applications that change at once whatever animate says — a game or a video player, say:

return {
    fullscreen = {
        animate = { duration = 200, easing = "inOutCubic" },
        instant = { app_id = { "mpv", "gamescope" } },
    },
    maximize = { animate = false },
}

The application is told its new size the moment you press the key, and its last picture is stretched until it has drawn one at that size — through the glide and after it, for a quarter of a second past the landing at most, and then the window is shown at whatever size the application has. Once it has answered the window is drawn as it is, with nothing in between, so a fullscreen game or video can be shown directly. An instant change is just that: nothing is stretched or moved, and the window is drawn as it is on the next frame. An app id is what sol.windows() calls app_id.

Your own bindings

A bindings section in user.lua, merged like every other section:

return {
    bindings = {
        ["super+b"]       = "firefox",
        ["super+shift+s"] = { "sh", "-c", "grim -g \"$(slurp)\"" },
        ["super+n"]       = function() sol.spawn("kitty", "-e", "nvim") end,
        ["super+g"]       = false,
    },
}

A string is a command line split on spaces; a list is one already split, for an argument with a space in it; a function is anything else, with the whole sol API in scope; false removes a shipped binding outright.

A combination you bind belongs to the compositor from then on, and while the session is unlocked the application with the keyboard never receives it. So binding a plain key such as escape, f1 or return takes that key from every window. This is why the overview binds Escape only while it is open (#174). Key bindings lists every shipped one, so you can see what a key does before you take it over — super+g above is a demonstration that tilts a window.

A binding here replaces a shipped one on the same combination. On purpose: refusing a clash would let you add a binding and forbid you to change one, and changing one is what most people come here for. It is not silent — solium --check marks every combination this section took over, and lists the ones it removed:

    super+b                     config.bindings
    super+q                     config.bindings, replacing a shipped binding
  1 binding(s) removed by the configuration:
    super+g                     config.bindings

Reading that list is also how you find out an edit dropped a binding, and it is the only way to catch super+whoops: a combination is whatever you press, so nothing can check the name for you.

Writing your own ~/.config/solium/init.lua is the other way, and is for rewriting the session rather than adding to it — it replaces the entry point whole. It can still require everything that ships, including bindings, which must stay last: sol.bind lets the later call win, and that ordering is what puts your config.bindings on top of the shipped ones.

require("modes")
require("tiling")

sol.bind("super+return", function() sol.spawn("kitty") end)

require("bindings")

Moving around by keyboard

super+arrows and super+h/j/k/l focus the window that way, super+shift+arrows and super+shift+h/j/l move it, and super+alt+k moves it up – super+shift+k is the keyboard layout. At the edge of a screen both go on to the next one. super+f is fullscreen, super+shift+m maximised and super+shift+space floats a window over the layout; each key again puts it back, and fullscreen and maximised glide there and back (Your own animation feel times them). Rebind any of them in bindings:

return {
    bindings = {
        ["super+ctrl+h"] = function() sol.focus_direction("left") end,
        ["super+alt+k"]  = false,
    },
    tiling = { move = "split" },
}

In tiling a move trades tiles with the neighbour level with it that way, and in a grid the opposite key puts both back; move = "split" makes it split the neighbour’s tile instead, as Hyprland’s movewindow does. modes.md has what each layout does with a direction.

Your own mode

A mode is a Lua module that reacts to events — a window opening, closing or taking focus, a drag ending, the monitors changing, and the rest modes.md lists — and asks for placements. tiling.lua is Hyprland’s dwindle and scrolling.lua is niri’s model; each decides where windows go and when, and leaves the arithmetic of the tree and the strip to crates/layout. Copy either into your own directory and it takes over.

modes.md is the guide, with a whole working mode in forty lines and the two mistakes everyone makes first.

Worth knowing

Each layer of a frame is drawn over its canvas: the window’s outer rect, grown by whatever bleed the layer declares. On the hardware that is on the GPU by default; nested, or where the GPU trial at startup fails, it is in software on the CPU. Bars and borders are cheap because most of that canvas is untouched, but a frame that paints across the entire window every frame will cost you, so favour transitions that settle over ones that loop forever.

A layer is drawn again only when Qt marks its scene dirty — something it renders changed — or while an animation in it is running. So an idle window costs a flag read, not a rasterisation. A decoration that animates continuously (pulse does, while focused) is drawn on every frame for as long as it animates. The pane styles’ README has the rest, under What it costs.

Editor completion

Solium ships a definitions file for lua-language-server, lua/meta/sol.lua, which describes every sol.* function with its arguments. Point the server at it and your editor completes sol. and checks what you pass. A .luarc.json in ~/.config/solium, beside your user.lua:

{
    "runtime.version": "Lua 5.4",
    "workspace.library": ["/usr/share/solium/lua"]
}

That is where a package puts Solium’s Lua. dev/install.sh puts it in ~/.local/share/solium/lua, and in a checkout it is crates/solium/lua; write the whole path either way. Naming the lua folder rather than lua/meta also lets the server follow require("modes") and the other shipped modules. The same file is the Lua API page of the documentation site.

Desktop modes

A mode is a Lua file. Tiling is one, scrolling is one, overview is one, and so is whatever you write. The compositor holds no opinion about any of them.

That is not a boast about extensibility — it is the architecture’s test. If a new mode needs new Rust, the transform layer is missing something, and that missing thing is the bug rather than your mode. Overview is about a hundred lines of Lua for exactly this reason: it was written to find out whether the claim was true.

Every mode above is captured by the compositor reading back its own framebuffer, in a nested session with the shipped configuration. Each row starts at the keypress and is every other frame of a burst taken twenty milliseconds apart, so the pictures are about forty milliseconds apart and a row covers the first fifth of a second. The first row is a window opening from super+return: what opens is the loading window, which the terminal fills once it has started, later than this row reaches. The other three start from three terminals on top of one another and press super+space, super+t and super+s. One engine drew all four rows, which is the whole argument on one page: a window opening, overview entering, and two layouts arranging are the same interpolation with different targets.

The two ways to move a window

Everything a mode does comes down to one of these, and picking the wrong one is the most common mistake.

sol.place(id, { x = 0, y = 0, w = 960, h = 1080 })   -- where it LIVES
sol.present(id, { x = 40, y = 40, w = 320, h = 180 })   -- where it is DRAWN

place is the layout’s authority. The window is really that size; the client is told, and asked to redraw. Use it for arrangements — tiling, scrolling, a window snapping back after a drag.

A placed rect is a tile, and the window is held inside it. A client that will not shrink as far as you asked — a terminal on its cell grid, a browser at its minimum width — is cut to the rect rather than drawn over its neighbours, and its frame and its hit test stop at the rect’s edge too. A client smaller than the rect keeps its own size. Two things let go of that:

sol.place(id, { x = 0, y = 0, w = 400, h = 300, tile = false })  -- placed, not tiled
sol.unplace(id)                                                   -- no longer tiled

tile = false is for a window you place without tiling it; dialogs.lua centres a modal over its parent that way, because a dialog that grows after it is centred must not be cut to the size it was centred at.

Two more things a placement can say, both for a window whose application has an opinion about its size (#115):

sol.place(id, { x = 880, y = 420, w = 800, h = 600,            -- the window, smaller
                tile = { x = 12, y = 12, w = 2536, h = 1416 } }) -- than the tile it has
sol.place(id, { x = 12, y = 12, w = 1262, h = 1416, cramped = true })

tile as a rect is the tile a window sits inside when it is placed smaller than it – tiling.lua does this for a window centred at its own maximum size. The window lives at the rect you place; the tile is what its client is held inside, and what a drag on the window’s edge moves the seam from, because a seam is the tile’s edge and not the window’s. cramped = true says the tile is smaller than the window’s own minimum, and comes back in sol.windows(); see An application’s own size. Each placement says it afresh, so one that does not say it is not cramped, and neither is a window placed with tile = false or let go with sol.unplace. sol.unplace is for a layout letting go, and modes.use sends it for every window whenever the layout in charge changes — so a mode registered through modes gets it for free, and one that is not must send it itself. Maximising and fullscreen take a window out of its tile on their own, and the way back puts it in again. A script cannot see that a window is maximised or fullscreen, so a layout that places every window on every pass (as tiling.apply does, and dialogs.lua for every floated window) places those too. The window itself stays where it is, over the arrangement. A tile placed is kept as the tile the window goes back into; a placement with tile = false changes nothing, so a floated window goes back to where it floated. The one placement that moves such a window is a move by key onto another monitor: see Focus and move by direction.

present is a transform. The window still lives where it lived and the client never learns anything happened; it is simply drawn somewhere else. Use it for anything temporary — overview, an app switcher, a peek, a genie. Then:

sol.present_clear(id)   -- animate back to real geometry and stop transforming

The distinction is why leaving overview is exact rather than approximate. The layout was never disturbed, so there is nothing to restore.

A present that lands exactly on the window’s own frame (no rect, no rotation, full opacity: sol.present(id, {})) lets the window go, as sol.present_clear does, rather than holding it there.

sol.present_from(id, rect) is the third: draw the window at rect and animate it to where it lives. That is every “appears from somewhere” animation — a window opening, or growing out of a dock icon.

Depth and pivot

sol.present(id, { z = 2 })                       -- drawn in front
sol.present(id, { rotate_y = 20, pivot_x = 0 })  -- turns about its left edge

z is draw order and nothing else. Equal values keep the order the stack gave them, so the default costs nothing — and a window raised above its neighbour is still clicked where the layout put it, because rect stays the truth for input. It orders windows among themselves. With fullscreen.covers = "top", the default, the front window on the workspace in view is lifted into a band of its own while it is fullscreen, above the top layer and above any window’s z.

pivot is a fraction of the window, not pixels: (0.5, 0.5) is the centre and is the default, (0, 0) the top-left corner. Each axis defaults on its own — pivot_x = 0 means the left edge and says nothing about the vertical. Outside 0..1 means what it says rather than being clamped: pivot_x = 2 hinges the window about a line off to its right, which is a door on a frame beside it.

A number that cannot be drawn with is dropped rather than drawn: a NaN depth cannot be ordered against anything, a non-finite pivot puts every corner of the window at NaN, and both fall back to the default with a line in the log. An infinite z is kept — math.huge is a legible “above everything”.

Both belong to a window, so both are sol.present keys. sol.present_group takes neither, and that is deliberate: a pivot on a selection would have to overrule each member’s own — a member is drawn through one matrix, turning about one point — and a depth would raise a desk’s windows above the desk next door while leaving its own wallpaper behind, because depth orders the windows and scripted surfaces are drawn in fixed layers. Turning a whole desk as one shape needs a rectangle for the selection, which no group has yet.

Turning, fading and bending

The rest of what sol.present takes, all of it animated by the same clock and all of it combinable with a rectangle, z and a pivot:

sol.present(id, { opacity = 0.5 })                     -- the compositor's alpha
sol.present(id, { rotate_y = 35, perspective = 900 })  -- tilted, in perspective
sol.present(id, { deform = {                           -- pulled into a rectangle
    effect = "genie", axis = "down", spread = 1.4,
    to = { x = 900, y = 1400, w = 120, h = 24 },       -- or { window = id },
} })                                                   -- or { surface = name }

opacity is the compositor’s, not the client’s: 0 draws nothing, and a window at 1/255 or less takes no clicks. rotate_x, rotate_y and rotate_z are degrees, applied in that order about the pivot, and perspective is the viewer’s distance in pixels, which is what makes a receding edge shrink rather than merely narrow. None of them moves a click: the window is still clicked across its flat rectangle.

deform bends the window with a vertex effect from crates/effects. There is one, "genie": progress from 0 (where the window is) to 1 (all of it inside to, the default), spread for how much of the window moves at once (0 is rigid, 1 by default), and axis, "down", "up", "left" or "right", for which edge leads. to is required. { window = id } and { surface = name } are looked up again on every frame, so the effect follows its target as it moves; a rectangle is fixed. A target that no longer exists draws the window flat, and an effect name this build does not have is logged and ignored. Animating to a deform starts from none, and sol.present_clear animates back out of one.

sol.present_group takes opacity and the rotation keys too, applied on top of each member’s own, and a third argument, { duration = ..., easing = ... }, for a selection that moves at its own speed.

Moving more than a window

A transform names a selection, and a selection can hold things that are not windows at all:

sol.group("desk-2", {
    windows  = { 3, 7 },          -- by id
    surfaces = { "wallpaper-2" }, -- anything sol.surface declared
    monitors = { "DP-1" },        -- everything drawn on a screen
    monitor  = "DP-1",            -- which instance of each surface above
})
sol.present_group("desk-2", { x = -2560 }, { duration = 300 })
sol.present_group_clear("desk-2")
sol.group("desk-2", false)        -- take the name away

One call, one animation, one target. The wallpaper travels because it is in the selection — the compositor has no idea what a wallpaper is, and does not need one.

Three things to know before using it:

x and y are a displacement, not a destination. sol.present takes the rectangle a window is drawn at; a selection has no rectangle of its own, so { x = -2560 } means “a screen to the left of wherever each member already is”.

It composes with each member’s own transform. A window you have also sol.presented is drawn at its own rect plus its selection’s displacement, so a window tilted inside a moving desk stays tilted within it. The corollary is the one that catches people: a mode handing absolute rectangles to a window in a carried selection is placing them in that selection’s space. That is why overview.lua shows the desk in front of you rather than every desk at once.

Membership changes are animated. A window that leaves one selection for another would otherwise have the difference between the two displacements land on it in a single frame; instead it is held where it was drawn and animates from there, the same rule sol.present follows for a window entering a mode. The duration is whatever sol.animate last set.

A selection naming a window that has closed, or a surface nothing declared, simply does not contain it. There is nothing to clean up.

What a mode is told

sol.on("open",   function(id) end)                 -- a window's life began
sol.on("surface", function(surface, action, data) end) -- a hosted scene sent an action
sol.on("closing", function(id) end)                -- a close was asked for
sol.on("refused", function(id) end)                -- ...and declined: it is back
sol.on("close",  function(id) end)                 -- it is gone
sol.on("focus",  function(id) end)                 -- the keyboard moved
sol.on("activate", function(id, why) end)          -- a window asked to come forward: "launch" or "request"
sol.on("drop",   function(id, x, y) end)           -- a drag finished
sol.on("resize", function(id, edge_x, edge_y, horizontal_side, vertical_side) end)
sol.on("scroll", function(dx, dy) end)             -- a modified wheel turn
sol.on("click",  function(x, y) end)               -- only while grabbing input
sol.on("layout", function() end)                   -- the room windows get changed
sol.on("monitors", function() end)                 -- the screens are not the screens you knew
sol.on("restore",  function() end)                 -- you have replaced a running session
sol.on("direction", function(verb, dir) end)       -- a direction key: "focus" or "move", and which way
sol.on("keyboard", function(state, changed) end)   -- the layout, Caps Lock or Num Lock changed: "layout", "caps" or "num"
sol.on("text_input", function(field, why) end)     -- the focused text field: "field", "caret" or "framed"
sol.on("fullscreen", function(id, entering) end)   -- it went fullscreen, or left: answer with sol.animate
sol.on("maximize", function(id, entering) end)     -- it was maximised, or restored: the same

Every handler, and every binding, has 100 ms, on a clock the compositor starts for each one, so nothing a handler calls puts it off: past that it is stopped with an error in the log, which names the file and line it was written at and the file and line it was stopped at, so a loop in one cannot freeze the desktop, even inside a coroutine it makes, around a pcall, xpcall or load, which hand the stop on, or around sol.focus_direction, and the other listeners still run. The direction listeners’ time counts against the handler that called sol.focus_direction or sol.move_direction, so one stopped there stops that handler too. A __gc finalizer, and the __close of a to-be-closed variable in the function a stop interrupts, run where no hook does, so the deadline cannot stop a loop in either: keep them short. A listener stopped three times stays off until super+shift+r, and so does a done of sol.act, whose stops are counted the same way, by function: a done written inline is a new function at each sol.act, so it is stopped each time rather than taken out (script::tests::a_listener_that_never_returns_is_stopped_and_the_others_still_run, script::tests::a_binding_that_never_returns_is_stopped, script::tests::a_handler_that_calls_sol_deadline_is_still_stopped, script::tests::replacing_sol_deadline_leaves_each_listener_its_own_deadline, script::tests::a_listener_that_never_returns_inside_a_coroutine_is_stopped, script::tests::a_listener_that_retries_with_pcall_is_stopped, script::tests::an_xpcall_handler_that_never_returns_is_not_called_for_the_stop, script::tests::a_listener_that_retries_load_is_stopped, script::tests::a_binding_that_loops_on_focus_direction_is_stopped, script::tests::a_direction_listener_stopped_at_the_deadline_stops_its_caller_too, script::tests::a_close_run_after_a_stop_is_not_under_the_deadline, script::tests::a_gc_finalizer_is_not_under_the_deadline, script::tests::a_stopped_listener_is_logged_with_its_file_and_line, script::tests::a_stopped_binding_is_logged_where_it_was_written_and_stopped, script::tests::a_handler_stopped_at_its_focus_direction_is_logged_at_that_call, script::tests::a_listener_stopped_three_times_is_taken_out, script::tests::a_done_stopped_three_times_is_not_called_again, script::tests::an_inline_done_is_stopped_each_time_and_never_taken_out). An actions.override is a listener actions.lua writes for you, so its stop names actions.lua as where it was written, and the override’s own line as where it was stopped (script::tests::a_stopped_override_is_logged_where_it_was_stopped).

surface is how a sol.surface declared with interactive = true talks back: its scene calls Solium.send(action, data), and you are told the surface’s name, the action and its data, a table, a value or nil, every action in the order it was sent (script::tests::a_surface_action_reaches_lua_with_its_data, state::tests::real_client::reflow_on_close::hosted::two_actions_from_one_frame_both_reach_lua_in_order); sol.act(action, data, done) performs the compositor’s verbs, and lua/actions.lua hands it the ones a scene sends (shell-boundary.md says which; ricing.md has an example). Declaring the same surface again with new properties writes them into the live scene rather than rebuilding it. keyboard and text_input are for something that reacts to typing rather than to windows: ricing.md says when each fires and with what, and lua/keyboard_indicator.lua uses both. Eight of the rest are worth reading twice.

open fires when the window opens, and for a launched window that is before its application exists. A window started with sol.spawn begins its life when the user asks for the program: your mode is told then, gets to place the window then, and the application appears inside it later. That is loading.reserves_a_slot, which is on by default. A window its application opens by itself — a second browser window, a dialog — is told at its first frame instead. Nothing special is required of you for either — but it is why open is the event that puts a window into your arrangement, and layout is not. A layout keeps its own structure and adds to it on open; layout only means “re-run what you already hold”.

A close is three events, because a close is a request. The compositor cannot take a window away; it can only ask the application to go, and an application with unsaved work puts up a dialog and stays. So:

eventsentmeans
closing(id)the moment a close is asked for – the frame’s button, sol.close, super+q – as the window starts fadingreflow now, if you want to
refused(id)when the application declines – it said nothing for a second, or answered with a dialog – and the window is back on screenput it back, if you took it out
close(id)when the window is goneforget it

In that order. After closing exactly one of the other two follows, and refused never follows close: once a window has gone, nothing brings it back. A refused window is an ordinary window again, and closing it a second time starts over with a second closing. A window whose application quit on its own – exit, its own Quit, a crash – was never asked, so it gets close and nothing before it; it still fades out where it stood, from what the application last showed – in front of the windows it was in front of and behind the rest – while a layout that reflows at close grows its neighbours into the space, behind it. Its row is in close’s own snapshot, leaving, and in no snapshot after.

close still means gone, and nothing else. Anything a script keeps about a window – dialogs.forget, tiling.exiled – is dropped there and not at closing, because a window that was only asked may be about to come back. refused is not open: the window never went, and nothing about an arrival should run again.

While a window is between closing and whichever comes next, its row in sol.windows() says leaving = true (and so does its row in close’s own snapshot). After close it is in no snapshot but that one. It is fading where it stood whatever you do: the compositor pins the rectangle it is drawn at, so placing it moves nothing you can see, and it stays in front of any window you move into its space – as does a refused window while it fades back in. It also stays cut to the tile it was closed in, so a client wider than that tile does not spill or squash as it fades: sol.unplace, or tile = false, on a window that is leaving waits, and takes effect if a refused close brings it back. modes.use counts on that, since it lets every window go.

The shipped layouts close up at closing, while they are the layout in charge, and put the window back at refused; reflow_on_close = "when_gone" in their section of config.lua makes both events do nothing and leaves the reflow to close, which is how every close worked before #128. Closing up at once has one cost worth knowing: the compositor waits a second for an application to go before it takes the silence for a refusal, so one slower than that to quit is refused and then closes, and the layout moves three times – at the press, at refused, and at close.

A layout that listens for neither event hears exactly what it always heard – close, once the window is gone – so a mode written before these two existed keeps working unchanged, and keeps its slot for a closing window until the application has gone. To close up at once, handle closing, leave leaving windows out of any arrangement you build from sol.windows(), and put the window back on refused.

focus is heard for a new window too, in every mode and for an X11 window as much as a Wayland one. The compositor gives a window the keyboard at its first frame, once your open handler has placed it, and only if it is headed somewhere the user can see; it does that as sol.focus would, so you hear focus for it. If your open handler calls sol.focus itself, yours is the last word and the compositor does not give it again. A window launched with sol.spawn gets it the same way when its application arrives; a sol.focus for it during open finds no application yet to give it to.

activate says a window asked to be brought forward, with an activation token such as a notification you clicked hands it, and it comes after the compositor has answered. That answer is the same whatever the token: a window on a workspace nobody is looking at, or one being closed, is refused outright – it is not focused, you hear no focus, and the keyboard stays where it was. Anywhere else it is focused first and asked afterwards: you hear focus, and if your handler brings the window into view (the scroller scrolls its column onto the screen) it keeps the keyboard. If it is still somewhere nobody can see once the layouts have had their say, the keyboard goes back to a window on screen. Then you hear activate(id, why), and why says who asked:

whysent whenthe shipped scripts
"launch"you launched an application that was already running, and it answered by bringing forward a window it hadshow the window’s workspace on its own monitor, and focus it
"request"anything elsedo nothing more

"launch" is Firefox, Telegram, any GTK or Qt application that keeps one instance: sol.spawn opens a window for the launch, the program passes the launch on to the instance running, and that instance brings forward the window it already has, with the launch’s own token. That window is not the launch arriving. The window opened for the launch dissolves, as one whose application never came does, and you hear its close; the window brought forward keeps its id, its workspace and its tile – a tile the launch’s window split closes up again as that window goes – and you hear no open or close for it. So the view going to it is yours to do, and workspaces.lua does it, as a dock does for an application that is running – without that, a launch whose window is on another workspace shows nothing at all. A window the application opens for the launch, one per activation or from a launcher that forks, is the launch arriving as before: it appears in the window that was opened for it, and there is no activate.

"request" is followed by nothing in the shipped scripts because nothing checks where a token came from: any client can make one for itself, and a view that followed every request would be one any application could pull away from you.

resize gives you where the dragged edge should go, not where the pointer is and not a delta. edge_x and edge_y are in the same coordinates tree:layout returns and sol.place takes — one per axis, so a corner drag’s two seams each get their own.

A position rather than a delta, deliberately: a delta would be measured against a layout your own last response just changed, and the windows shake for as long as the button is held. But a position of the edge rather than of the pointer, also deliberately, and that is #124. A seam set from the cursor lands under the cursor, so a drag begun anywhere except exactly on the edge threw that edge across to the cursor on its first frame — half a border’s width for a border drag, and most of a window for super+right-button, which starts a resize from wherever inside the window you happened to press. Handed the edge instead, the same arithmetic makes the gesture relative: the edge starts where it already is and moves as far as the pointer moves.

“Where it already is” means where you put it — the rect your last sol.place for that window carried — and not where the client drew itself. The two differ whenever a client commits a size other than the one it was asked for, which a terminal on a cell grid does every time. Hand that back on a first frame and your seam moves by the client’s rounding before the pointer has travelled a pixel, so the compositor sends your own number back to you.

On an axis this drag does not move there is no dragged edge, and the pointer’s own coordinate comes through there instead. Check the side before using the coordinate — which is what the side is for — and you will never see it.

horizontal_side and vertical_side are the sides being dragged, not the axes: "left" or "right", "top" or "bottom", and nil for an axis this drag does not move. A corner drag fills both, because a corner drag moves one seam per axis. They were booleans until #120, and a boolean cannot choose a seam: a window sitting on the right of a vertical split has that split’s seam on its left, so “the horizontal axis is in play” was true whichever edge the hand was on, and dragging the right edge moved the left one instead. Hand the side straight to tree:drag_seam.

click only arrives while you hold input. sol.grab_input(true) takes keys and clicks away from clients, which is what a mode needs while it owns the screen. Release it when you leave, or nothing will ever reach a window again.

restore fires after a reload and never at startup. That asymmetry is the event. See below.

What survives super+shift+r

A reload throws the whole Lua state away and reads your files again. The session does not go with it: the windows, the monitors, the workspace in view and the layout in charge are all still exactly what they were. So your mode comes back as a stranger to a desktop it was running a moment ago, and it is entitled to exactly two things.

Whatever you handed to sol.keep.

local state = sol.keep("my-mode", { showing = 1 })

You get the table the last load left under that name, or the defaults the first time. Mutate it in place; the host takes a copy when the reload happens. It holds plain data — numbers, strings, booleans and tables of those — and the trees and strips sol.layout.tree() and sol.layout.scroller() make, which come back whole. A function in there cannot cross and is named in the log rather than dropped in silence.

Keep as little as you can. Anything you can work out again from sol.windows() and sol.monitors() should be worked out again, because a keep is a claim about the past that nothing checks.

An arrangement is the exception, because it cannot be worked out again: the window list says which windows there are, not where the user put them. The shipped layouts keep theirs. Before they did, a reload rebuilt each one from the window list, topmost first, and windows traded places (#118). A strip that comes back still has the widths the last file gave it, so scrolling.lua hands it the new ones with view:configure(config.scrolling).

And the world, re-announced: restore, then monitors, then layout.

restore is the moment every script has loaded, which is the earliest you can touch another module’s registrations — lua/modes.lua uses it to make the remembered layout active again, which it cannot do at its own top level because lua/tiling.lua has not registered itself yet. monitors then says the screens are what they are, and layout asks you to arrange.

Without this, a reload was a quiet way to lose the session: on workspace 3 it came back believing it was on workspace 1, put every window on desk 1, and drew the desk two screen-widths off-stage with no key that brought it back. That is issue #116, and both halves above are what it cost.

What a mode can ask

sol.windows()          -- every window: id, x, y, w, h, title, focused, monitor,
                       --                modal, parent, leaving, app_id, min, max,
                       --                cramped, shown
sol.monitors()         -- every monitor: name, x, y, w, h, whole, scale,
                       --                 transform, focused, primary, power
                       --   (x, y, w, h: the work area, less layer-shell
                       --   bars and hosted reserves)
sol.monitor()          -- the active monitor's work area
sol.monitor(id)        -- the work area of the monitor that window is on
sol.cursor()           -- { x, y }
sol.window_at(x, y, skip)  -- the id under a point, optionally skipping one

sol.windows() is a snapshot taken fresh for your handler, never a live view. A window closing while you hold its id is ordinary: place and present on an id that no longer exists do nothing rather than failing.

x, y, w and h are where the window lives, which is what the layout thinks. No field says where it is being drawn: a mode that presented it somewhere else knows, because it said so. To ask what the user is looking at, ask sol.window_at, which answers by what is on screen — the rectangle each window is drawn at, whether it is visible at all, and whether the monitor under the point is showing it.

power is "on" or "off". A monitor turned off, by sol.monitor_power or by idle.screens_off_after, keeps its place, its work area and its windows, and no monitors or layout event fires for it: keep arranging it as if it were lit, because input turns it back on exactly as it was.

modal is a window that says it is a modal dialog — a save prompt, a permissions box — and parent is the window it belongs to. A layout should leave a modal out of its arrangement and put it over its parent; dialogs.lua is that policy, shared by tiling.lua and scrolling.lua so the two cannot disagree. Onto its parent’s monitor, which is not always its own: a window is mapped at the origin and belongs to whichever screen that lands on, so a dialog for a window on the second screen arrives on the first one and has to be moved off it. dialogs.place is where that is decided, from the rect it is centred on rather than from the screen it was mapped on.

Dragging one is allowed and sticks: dialogs.dropped records where it was dropped as an offset from its parent, so the prompt you pushed off the sentence it was covering stays off it, and still follows the document when the layout moves it. Staying above that document is not the layout’s business at all — the compositor keeps a modal over its parent in the stack, whatever raises what (Solium::map_stacked).

parent has three values and it is worth knowing why. It is the parent’s id when there is a window to point at, false when the client named a parent that is not on screen — not mapped yet, not ours, or closed while its dialog was still up — and absent when no parent was ever named. So if window.parent then is the right question for “have I got something to centre on”, and window.parent == false is how you tell a lost parent from no parent at all.

min and max are how small and how large the window’s own application says it can be, { w = ..., h = ... } – in the same terms as w and h, frame included, so they compare with a tile directly – and absent when it limited neither side. 0 on one side is no limit on that side. They are what the client has committed, never a size it has asked for and not yet drawn to, and a change to either re-runs layout once per change. cramped is the layout’s own word coming back: true while the layout in charge last placed the window with cramped = true. app_id is the application’s name for itself – a Wayland application’s app_id, an X11 one’s WM_CLASS class – and empty until the application has arrived. shown is whether the application has been shown yet: false for a window launched with sol.spawn from its open until its application’s first frame, which is how a layout tells limits that arrive in time to decide where a window goes from limits that changed on a window already on screen.

skip on window_at exists because of one specific bug: a new window is already mapped and under the pointer, so asking “what am I pointing at” without skipping it names the window as its own split target.

A layout runs per monitor

There is one global coordinate space and every monitor is a rectangle in it. That is the whole mechanism: a window is on the second screen because its x lands there. There is no per-screen coordinate system and nothing to convert.

The consequence is the thing to get right. sol.monitor() answers one screen, so a mode that lays every window out against it piles both monitors’ worth of windows onto whichever one the pointer happens to be on. A layout is per monitor:

local monitors = require("monitors")

for _, each in ipairs(monitors.each()) do
    -- each.monitor is the rect; each.windows are the windows on it
end

monitors.each() always lists every monitor, including one with nothing on it — a layout has to hear about an empty screen, because that is what tells it the last window left.

Anything stateful is keyed by monitor as well as workspace. tiling.lua keeps a dwindle tree per pair and scrolling.lua a strip, via monitors.key:

local function tree_for(monitor)
    local key = monitors.key(workspaces.on(monitor), monitor)
    ...
end

Two reasons, and the second is the one that bites. The screens are different sizes, so a split or a column width that reads well on one is wrong on the other. And a window moved across has to leave one arrangement and join the other: a window in two trees is a window given two slots, and it ends up in whichever was laid out last. adopt is where that settles — missing from its new screen’s tree, still in its old one’s, both halves fixed in one pass.

One sol.animate for every screen. Two monitors rearranging at once is one movement; see animation.md on why the feel is set per batch and not per window.

A window is drawn only on the monitors it lives on

Its slot decides that, not its transform. A transform can move a window around its own monitors and off them; it cannot put it on somebody else’s.

This is worth knowing before you write a mode that moves a window a long way, because it is what makes such a mode work on more than one screen. Workspaces are the example. A workspace switch does not move windows — it draws the ones belonging to other workspaces a screen away, and with one monitor “a screen away” is off the desktop, which is how they are hidden. With two side by side, one screen away is the other monitor: switching the left screen’s workspace threw its windows onto the right screen, on top of what was already there.

The intent was always containment; one screen just made moving and hiding the same thing. So the rule is enforced where it belongs, and the slide stays one screen long — which is what makes it read as a slide. Offsetting by the whole desk instead would hide them correctly and look wrong: the window would leave the screen halfway through and the next arrive halfway through, with empty screen in between.

The slot and not the drawn rect, so a window straddling the bezel — dragged between screens, where the slot itself is on both — is still drawn on both.

Workspaces

config.workspaces.per_monitor decides whether each screen has its own workspace in view. On by default: super+2 switches the monitor the pointer is on and leaves the other showing what it was. Off, one switch moves every screen.

Both are real desktops, and the difference is what you take a workspace to be — a screenful, or a whole desk. workspaces.lua shares every line between them: which workspace a monitor shows is looked up by monitor either way, and with the setting off every monitor looks up the same entry.

Launching an application that is already running goes to its window where it is: workspaces.lua shows that window’s workspace on the window’s own monitor – so with per_monitor on, the screen in front of you goes on showing what it was if the window is on the other – and focuses it. That is its activate handler; see What a mode is told.

A workspace is a selection, one per monitor per workspace, named desk-<n>@<connector>. workspaces.lua declares it from the windows on that desk plus that desk’s background, and carries it with a single sol.present_group — which is why a per-workspace wallpaper travels with the switch. Set config.wallpaper to a list of images to get one; a single image belongs to the monitor and stays put, because every desk sharing one picture makes a wallpaper that slides indistinguishable from one that does not.

If you keep per-workspace state of your own, key it by monitor as well. monitors.key(workspaces.on(name), name) is the string tiling.lua and scrolling.lua both use, and tree_for takes only the monitor — the workspace that screen is showing is something workspaces knows, and threading it through every call site is how one of them ends up asking for the wrong screen’s.

workspaces.lua declares the workspaces it keeps with sol.workspaces{ arrangement, groups, windows } whenever they change, which is how a hosted shell’s Workspaces and WorkspaceList know them, and it answers workspaces.go and windows.send from a scene through actions.lua, when the configuration keeps that file and requires it before workspaces, as the shipped init.lua does: workspaces.lua looks for it once, as it loads. Without it, the configuration routes a scene’s actions itself, and workspaces.lua does not route them a second time. A configuration that keeps workspaces some other way, tags say, declares those, each <group>/<id> once. See shell-boundary.md.

monitors.active() is the monitor the pointer is on — where a new window goes, and what a binding pressed with no particular window in mind is about. It is the pointer and not the focused window on purpose: look at the second screen, click the empty desktop, press the key for a terminal, and a focus-based rule would open it on the screen you just looked away from.

The primary flag is a different question and answers a different one: it is where things belonging to one screen go — a layer surface that named no output, or a hosted shell or sol.surface declared with on = "primary". The hosted shell is on every monitor unless shell.on names one. It does not move, which is the whole point of it. See shell-boundary.md.

x, y, w, h are the work area — the monitor less whatever bars have reserved, layer-shell clients and hosted surfaces alike — and whole is the monitor itself, which is what a wallpaper or a fullscreen window covers. Both are in the global space, so either can be handed straight to sol.place. Copy the rect before adding keys to it; the one you were given belongs to the snapshot.

Focus and move by direction

sol.focus_direction("left")   -- or "right", "up", "down"
sol.move_direction("left")
sol.toggle_fullscreen(id)     -- fullscreen, or back; the focused window with no id
sol.toggle_maximize(id)       -- maximised, or back; the focused window with no id

The compositor does nothing with these but tell every direction listener the verb and the direction, and a direction that is not one of the four is an error. lua/direction.lua is the listener that ships, and it asks the layout in charge, by two functions on the table you gave modes.register:

function mine.focus_direction(dir) ... return true end
function mine.move_direction(dir) ... return true end

Answer false, or leave either out, and the key gets what a desktop with no layout in charge gets: focus goes to the nearest window that way, and a move trades places with it, each window keeping its size. tiling.lua answers false for focus from a window it does not tile – a dialog, or one floated with super+shift+space – so the keyboard still finds its way out of one.

Which window is that way depends on what kind of windows they are. A tile’s neighbour is wholly past its edge and level with some of it – a tile below and to one side is not to its side – and the nearest facing edge wins. Floating windows overlap and sit off on diagonals, so for them, failing any such neighbour, the nearest centre further that way counts too.

At a monitor’s edge both go on to the next monitor that way, and the desk it is showing. The next monitor is one wholly past this one’s edge, as wlroots has it: a portrait screen standing beside a landscape one is beside it and never below, and a smaller screen top-aligned next to a bigger one is above nothing. Of two that way, the one level with the window wins, so with two screens stacked beside a big one the window’s own height picks between them. On the next monitor the nearest centre counts for tiles as well, since a screen of another height may have nothing level with the window.

direction.find(from, dir, on, loose) is that search, for a layout’s own rectangles: on(name) lists them for a monitor, loose is the floating rule, and you get back the nearest, its monitor, and whether that meant crossing to another one.

focusmove
tilingthe tile that waytrades tiles with it; tiling.move = "split" splits its tile across that tile’s longer side instead
scrollingthe next column, or the window above or below in onethe column, or the window within its column
no layoutthe nearest window that waytrades places with it

Across a monitor a move takes the window alone: into the tile it arrives beside in tiling, into a column of its own in scrolling, and as far across the new screen as it was across the old one with no layout. The shipped keys, all of them replaceable in config.bindings:

keys
super+arrows, super+h j k lfocus that way
super+shift+arrows, super+shift+h j l, super+alt+kmove that way
super+ffullscreen, and back
super+shift+mmaximised, and back
super+shift+spacefloat over the layout, and back into it

Moving up on k is super+alt+k because super+shift+k cycles the keyboard layout. A window floated with super+shift+space is, to both layouts, what a dialog with no parent is: out of the arrangement, centred on its screen at the size it had, until the key puts it back. It stays floated through a reload.

super+shift+m on a fullscreen window leaves fullscreen for maximised, or, for one that was maximised before it went fullscreen, for where it was before either. super+f and super+shift+m act on Wayland windows only for now: an X11 window under XWayland does not go fullscreen or maximised by key.

A fullscreen or maximised window stays so when it is moved. On its own monitor the move happens behind it: in tiling it trades tiles with the one that way, and leaving fullscreen or maximised goes into the tile it has now. Onto another monitor it is fullscreen or maximised there, drawn on the workspace that monitor shows and keeping the keyboard, and leaving stays on that monitor: in its tile there, or with no layout at the same place on the new screen as it had on the old one. A window floated with super+shift+space and then made fullscreen stays fullscreen through every layout pass, and leaving goes back to where it floated.

Going fullscreen or maximised, and back

The compositor makes the change and the scripts choose how it looks. On the key, or when an application asks, the window is told its new size at once and lives at its new rectangle – the whole monitor it is on, its work area, or the place or tile it came from. Then fullscreen or maximize is told, (id, entering), and once every listener has run the window’s picture glides there from wherever it is drawn, through the same transform a layout’s glide uses, with the timing a listener set with sol.animate. With none set, the change is instant:

sol.on("fullscreen", function(id, entering)
    sol.animate({ duration = 260, easing = "outCubic" })
end)

lua/fullscreen.lua is the listener that ships, reading fullscreen and maximize in config.lua – animate, or false, and instant.app_id, each section on its own (ricing.md). The rest is the compositor’s, whatever the listener says:

  • From what is on screen. Pressing the key again half way turns the window round where it is drawn, not where it was headed or where it came from (state::tests::real_client::fullscreen_glides::a_second_toggle_mid_flight_starts_from_where_the_window_is_drawn).
  • Answered at once. The application is configured on the key, not when the glide lands (state::tests::real_client::fullscreen_glides::the_client_is_told_its_new_size_on_the_toggle), and the window is held at its new rectangle until the application draws at that size, as a window whose edge you drag is: its last picture is stretched into the rectangle the glide has reached, and after the glide lands, into the rectangle it landed on (state::tests::real_client::fullscreen_glides::a_slow_client_is_drawn_stretched_until_it_answers). An application that says nothing is held a quarter of a second past the landing, and then shown at the size it has (state::tests::real_client::fullscreen_glides::a_client_that_never_answers_is_drawn_as_it_is_once_its_patience_runs_out); an edge drag on it takes it over (state::tests::real_client::fullscreen_glides::an_edge_drag_takes_a_window_its_change_is_holding).
  • Plain at rest. The transform is released when the glide lands, so a fullscreen game or video is drawn with nothing in between (state::tests::real_client::fullscreen_glides::a_window_glides_into_fullscreen_and_out_again). A monitor unplugged, a reload or a lock part of the way through changes that for nobody: the window comes to rest all the same (state::tests::real_client::fullscreen_glides::a_monitor_unplugged_mid_glide_leaves_the_window_at_rest, state::tests::real_client::fullscreen_glides::a_reload_mid_glide_leaves_the_window_at_rest, state::tests::real_client::lock_focus::a_lock_mid_glide_leaves_the_window_at_rest).
  • Instant is nothing at all. A change with no motion – animate = false, an application on the instant list, no listener – puts no transform on the window and holds nothing: the next frame draws it as it is, as before the glide existed, and stops one in flight (state::tests::real_client::fullscreen_glides::an_instant_change_draws_the_window_as_it_is_on_the_next_frame, state::tests::real_client::fullscreen_glides::an_instant_change_part_of_the_way_through_a_glide_holds_nothing).
  • Turned round, it keeps its way back. Pressed again before the application has drawn at the size it went back to, the rectangle kept for the next way out is the one it was told, not the monitor’s it has not left yet (state::tests::real_client::fullscreen_glides::turning_round_part_of_the_way_out_keeps_the_way_back).
  • On its own monitor. A window grows to cover the monitor it is on, and shrinks back on it (state::tests::real_client::fullscreen_glides::a_window_on_the_second_monitor_glides_to_cover_that_one).
  • Over the bars while it is big. A window going fullscreen goes over the bars as it starts to grow, and one leaving goes back under them once it has finished shrinking; a press goes to what is drawn there (state::tests::real_client::reflow_on_close::stacking::a_window_is_lifted_as_it_starts_to_grow_and_dropped_once_it_has_shrunk).
  • Into its tile with its own motion. A window going back into a tile is placed by the layout, and the change’s glide replaces the layout’s (tests/scenarios/fullscreen-tiled.lua). It shrinks in front of its neighbours, whichever the layout placed last, and a sweep part of the way through leaves it there (state::tests::real_client::a_tiled_window_leaving_fullscreen_stays_in_front_while_it_shrinks).
  • Not told twice. A listener that toggles the window back has that done at once and is not told it (state::tests::real_client::fullscreen_glides::a_listener_that_toggles_the_change_back_is_not_told_it_again).

The compositor’s move comes after the listeners’ commands, so a sol.present of the window in one is replaced by it. A window a mode was already presenting before the change – a thumbnail in the overview – is left where the mode draws it: the change is made, and the mode’s sol.present_clear brings the window to its new rectangle (state::tests::real_client::fullscreen_glides::a_window_a_mode_presents_stays_where_the_mode_draws_it).

The arrangements that ship

You do not have to compute geometry yourself.

local slots = sol.layout.grid(windows, area)          -- overview's grid
local slots = sol.layout.master_stack(count, area)    -- one big, the rest beside
local slots = sol.layout.strip(columns, area)         -- a row, with an offset

These are pure: windows in, rectangles out. Two are not, and cannot be:

local tree = sol.layout.tree()        -- dwindle
local scroller = sol.layout.scroller(config.scrolling)  -- niri's model

A dwindle tree is stateful because the arrangement is. Where a window lands depends on which window was split and where the pointer was, and no function of “how many windows are there” can recover that afterwards. Same for a scroller: which column is active and where the view sits relative to it are not in the window list.

They are held by the script that made one, not by the compositor:

tree:insert(id, target, x, y, options)
tree:insert_fitting(id, target, x, y, options)  -- the same, if it keeps options.minimum
tree:insert_largest(id, options)                -- the largest tile with room
tree:remove(id)
tree:contains(id)
tree:windows()
tree:layout(options)      -- the slots, to hand to sol.place; cramped = true on a
                          -- slot smaller than its window's own floor
tree:resize(id, "width", share, options)               -- keyboard: an axis
tree:drag_seam(id, "right", edge_x, edge_y, options)   -- pointer: a side
tree:swap(a, b)           -- two windows trade tiles; every split keeps its ratio

insert splits whatever it is pointed at, however small that leaves the halves. insert_fitting and insert_largest refuse a split that would leave a tile under options.minimum – { w = ..., h = ... }, frame included – and answer false with the tree untouched, so a layout can try one, then the other, then fall back to insert. That is what tiling.lua does; see below.

The two resize calls take different things on purpose. A drag names a side — the hand is on one specific edge, and which seam moves follows from that. A keypress names only an axis: super+equal means “wider” and says nothing about which neighbour gives up the room, so resize prefers the seam on the right or below and falls back to the other, with positive share always growing the window. Either way, a window flush against its container on that side has no seam there and nothing happens — the screen edge is not a seam.

drag_seam puts the named side of that window at edge_x (for "left" and "right") or edge_y (for "top" and "bottom"), reading only the one its side names — which is what lets a corner drag call it twice with one pair and have each axis take its own. Hand it the edge_x, edge_y a resize gave you and the gesture is relative; hand it the pointer and you have rebuilt #124. The ratio it computes is separately clamped to 0.05..0.95, so a window shoved hard against a seam stops there rather than vanishing, and to whatever keeps every tile on either side of the seam at options.minimum. Those clamps are the only bounds on a tiled drag: the compositor sends an unfloored edge, deliberately, because a floor measured in a window’s pixels cannot bound a seam’s position — see Tiling::drag_seam. A tile already under the minimum is not snapped up to it when grabbed, which would move it on the first frame: it cannot be shrunk further, and can be grown only while the tiles across the seam have room to give. A seam with a tile under the minimum on both sides therefore does not move at all, and that is what "allow" below usually leaves, since it halves a tile too small to split. resize is bounded the same way when it is given options; without them, as a script written before #134 calls it, only 0.05..0.95 applies.

options.floors is each window’s own floor, { [id] = { w = ..., h = ... } }, frame included: the smallest its application says it can be, or nothing for a window with no entry. Every call that takes options reads it. The tree lays each split out at the ratio it holds, then moves it just far enough for a window under its floor to reach it, taking the room from the other side down to its floors and options.minimum, and no further; the ratio it holds is left alone, so a window whose floor goes away hands the room back. insert_fitting and insert_largest refuse a split that would leave either window under its floor, and a seam stops at the floors beside it. A window the room is not there for is laid out short, and its slot says cramped = true. The scroller reads options.floors too, and never lays a column out narrower than the widest floor in it.

options is a monitor’s work area with gap, split, minimum and floors added — and, for the other arrangements, ratio (the master window’s share, for master_stack), column (a column’s share, for sol.layout.scrolling) and padding (around each thumbnail, for grid). Passing the monitor in rather than the tree asking for it is what lets one tree per workspace per monitor exist without any of them knowing about either. Copy the rect before adding keys — the one from sol.monitors() belongs to the snapshot.

A scroller holds columns, and each column holds windows stacked top to bottom:

scroller:insert(id, options)              -- a new column, right of the active one
scroller:insert_into_column(id, options)  -- into the active column instead
scroller:remove(id)
scroller:focus_window(id, options)        -- focus it, and bring its column into view
scroller:focus_sideways(by, options)      -- by columns: -1 left, 1 right
scroller:focus_vertically(by)             -- within the active column
scroller:move_column(by, options)         -- swap the active column with a neighbour
scroller:consume()                        -- pull the next column's focused window in
scroller:expel(options)                   -- push the focused window out into a column
scroller:move_to_column_of(id, target, options)  -- what a drop means here
scroller:widen(id, by, options)           -- by a share of the view, clamped
scroller:cycle_width(options)             -- through config.scrolling.widths
scroller:configure(config.scrolling)      -- new widths, same columns and view
scroller:scroll_by(dx)                    -- move the view, not the focus
scroller:focused()
scroller:contains(id)
scroller:windows()                        -- left to right, then top to bottom
scroller:layout(options)                  -- the slots, to hand to sol.place

Every column shares one width, so widen on any window in a column widens the column. Calls that move the focus to a column also bring it into view, which is what keeps a focused column from hanging off the edge of the screen. configure is for a strip sol.keep carried across a reload, which is not made again and would otherwise go on with the widths of the file it was made from.

Three more pure helpers sit beside strip: sol.layout.scrolling(count, options) lays out count columns, each options.column of the width, and sol.layout.scroll_to and sol.layout.strip_scroll_to answer how far to scroll to bring a column into view.

When a new window has no room

tiling.lua gives a new window the tile under the pointer, split across its longer side. When that would leave a tile under config.tiling.minimum it tries the tile’s other side — a wide, short tile with no room side by side may still have room one above the other — and when neither has room it works through config.tiling.overflow, in order, until a step places the window:

stepwhat it doeswhen it does nothing
"largest"splits the largest tile on this workspace that has roomno tile has room either way
"workspace"opens the window on the next empty workspace of the monitor it opened on, after the one in view and round againevery workspace has a window on it
"allow"splits the tile under the pointer anyway, below the minimumnever

The default is { "largest", "workspace", "allow" }. A list that runs out ends in "allow" regardless, with a line in the log, because a window has to go somewhere.

“Empty” does not count a window that is being closed, unless reflow_on_close = "when_gone" is keeping its tile. With workspaces.per_monitor off it means empty on every screen, since that workspace is every screen at once. A window that belongs to no workspace in particular is on every one — the view carries it along — so it leaves none empty; with workspaces.follow_new_windows off every window is one. The workspaces are the fixed set workspaces.count() describes, so nothing is ever created.

With follow_overflow on, the view goes with the window as workspaces.go takes it, and the keyboard goes to the window. For a window launched with sol.spawn that happens when its application arrives: until then there is no client to give it to, and the keyboard can still be on the window the view has just left. Off, the window is placed in its tile over there, and the view and the keyboard stay where they were — the compositor gives a new window the keyboard only if it is headed somewhere the user can see. Each window with no room then goes to another empty workspace, since the one the window before went to is no longer empty, whether or not it has room.

The decision is made at open. For a window launched with sol.spawn that is before its application has connected, so a window that overflows is placed in its final tile, on its final workspace, in the same dispatch that opens it – not in this workspace first and moved later.

Only a window being opened overflows, and only while tiling is the layout in charge. Everything that puts an existing window back into a tree — tiling.adopt on switching tiling on, on a reload and on a monitor change; refused, for a close the application declined; a dialog that stops being modal; a drop — never sends it to another workspace, whatever the list says. One that has nowhere in particular to go takes the tile insert would choose, either way, then "largest", then "allow". A refused window and a dropped one go back to the tile they were in or were let go over, either way, and below the minimum if that is what it takes: the largest tile is somewhere else, and a refused window’s arrangement has to come back as it was. And while floating or scrolling is in charge, tiling.lua still keeps new windows in its trees for later, the same way, without sending any of them anywhere.

The minimum is a tile’s, not an application’s: the tile is decided before any application exists to have an opinion. An application’s own minimum is the next section.

An application’s own size

Some applications will not go under a size – Firefox has a minimum width – and a few will not go over one, and they say so. Before #115 nobody listened: the tile was split as though the application had said nothing, the application drew itself at the size it insisted on, and that tile and every one beside it were wrong. sol.windows() has the two sizes now, and tiling.lua listens by default. You have the last word and not the application, so all three settings in config.tiling are yours:

settingdefaultwhat it does
client_minimum"respect"lay each window out at least as large as its application’s minimum, wherever there is room, taking the room from the windows beside it down to their own minimums and minimum; "ignore" lays out as though no application had one, as before #115
client_maximum"center"a window whose tile is larger than its maximum is its maximum size, in the middle of the tile; "ignore" leaves it in the tile’s corner at the size it chose, as before
client_size_ignore{}applications, by app_id, whose sizes are not believed at all – for one that claims a size it does not mean

Anything but "ignore" is read as the default. Respecting a minimum means:

  • a window under it is given a larger share, taken from the windows beside it, down to their own minimums and minimum and no further; the share goes back when the application’s minimum does;
  • a new window that would crowd one goes where overflow says, since a split that leaves either window under its minimum is refused like one that leaves a tile under minimum. A window launched with sol.spawn has no application yet when it is placed, so only the minimums of the windows it would split are known then. Its own arrives with its application’s first commit, before its first frame: the tile it was given is rebalanced for it, and if that still leaves it cramped it is taken out and placed again, once, by the same rule – the tile under the pointer where it was launched, then overflow;
  • a window whose minimum grows gets the arrangement rebalanced around it, the moment the application commits it;
  • a seam stops where a window beside it reaches its minimum, by the drag and by the keyboard;
  • and in the scrolling layout, a column is never narrower than the widest minimum in it.

A centred window’s edge still drags the seam, from the tile’s edge: tiling.lua places it with its tile as tile. And a floating window’s edge drag stops at its application’s minimum and maximum, because the frame would otherwise show a size the application is about to refuse. That one is the compositor’s, not a layout’s, so it has a setting of its own, and sizes.lua hands it over as it loads, with client_size_ignore, through sol.client_sizes:

settingdefaultwhat it does
floating.client_limits"respect"a floating window’s edge drag stops at its application’s minimum and maximum; "ignore" lets it go wherever the pointer does, as before #115

An application in tiling.client_size_ignore is not believed there either.

Cramped. When the room is not there – two windows that each need more than half the screen, side by side – the window is laid out smaller than its minimum anyway. Its application draws itself at the size it insists on and the picture is cut to the tile (#133), so it still does not cover its neighbours. tiling.lua says so once in the log, with the numbers:

tiling: window 1 needs at least 2500 wide, frame included, and its tile is 2364x1416; it is cramped, and its application's picture is cut to the tile

and places the window with cramped = true, which sol.windows() then says until the window has room again. A layout that says it is also run once more by the compositor, so a handler reading sol.windows() in that same layout pass sees it. A bar that wants to show it reads it there:

-- A strip across the top that names the windows short of room.
local function show_cramped()
    local short = {}
    for _, window in ipairs(sol.windows()) do
        if window.cramped and not window.leaving then
            short[#short + 1] = window.title
        end
    end
    local screen = sol.monitor()
    sol.surface("cramped", {
        scene = "cramped.qml",
        layer = "top",
        on = { x = screen.x, y = screen.y, w = screen.w, h = 24 },
        properties = { windows = table.concat(short, ", ") },
    })
end
sol.on("layout", show_cramped)
-- A window going is not a `layout`, and `close` still lists it, as leaving.
sol.on("close", show_cramped)

Re-declaring a surface with the same properties changes nothing, and a changed list is written into the live scene rather than rebuilding it; cramped.qml is any scene with a required property string windows. On the top layer the strip goes under a fullscreen window, as a bar does (fullscreen.covers); overlay keeps it over one. A window that is cramped and then has room again is named in the log again the next time it is short.

A whole mode

Above: three tiled windows at rest with their QML frames, super+space into overview, and super+space again, each a second after the key. The last frame differs from the first by zero pixels (magick compare -metric AE on the two captures), which is the property that matters — a mode that cannot put the desktop back exactly is a mode nobody will use twice. All of it is lua/overview.lua.

This is real and it works. Save it as ~/.config/solium/mymode.lua. A mode is loaded by init.lua, and the shipped one does not know about yours, so copy the shipped init.lua to ~/.config/solium/init.lua — it is share/solium/lua/init.lua under the prefix Solium was installed to (/usr for the Fedora package, ~/.local for dev/install.sh), and crates/solium/lua/init.lua in a checkout — and add require("mymode") after its require("scrolling"). Your copy then replaces the shipped file entirely: it keeps what it had when you copied it and does not pick up what a later version adds, so compare the two after an update.

-- Two columns on every monitor: the most recently raised window on the right,
-- the rest stacked on the left. Dialogs float over the window they belong to.
local modes = require("modes")
local monitors = require("monitors")
local workspaces = require("workspaces")
local dialogs = require("dialogs")

local mine = { active = false }
local gap = 12

local function arrange()
    if not mine.active then return end

    -- The desk each monitor is showing, without windows on their way out.
    local shown = {}
    for _, window in ipairs(workspaces.visible()) do
        if not window.leaving then
            shown[#shown + 1] = window
        end
    end

    sol.animate({ duration = 220, easing = "outCubic" })

    local placed = {}
    local function put(id, rect)
        sol.place(id, rect)
        placed[id] = rect
    end

    for _, each in ipairs(monitors.each(shown)) do
        local area = each.monitor
        local windows = {}
        for _, window in ipairs(each.windows) do
            if not dialogs.floats(window) then
                windows[#windows + 1] = window
            end
        end

        if #windows > 0 then
            local half = (area.w - gap * 3) / 2
            -- The most recently raised window takes the right half.
            put(windows[1].id, {
                x = area.x + gap * 2 + half, y = area.y + gap,
                w = half, h = area.h - gap * 2,
            })
            -- The rest share the left half, top to bottom.
            local rest = #windows - 1
            if rest > 0 then
                local tall = (area.h - gap * (rest + 1)) / rest
                for index = 2, #windows do
                    put(windows[index].id, {
                        x = area.x + gap,
                        y = area.y + gap + (index - 2) * (tall + gap),
                        w = half, h = tall,
                    })
                end
            end
        end
    end

    -- Dialogs last, over whatever they belong to, on that window's screen.
    dialogs.place(shown, monitors.named, placed)
end

sol.on("open",   arrange)
sol.on("close",  arrange)
sol.on("layout", arrange)

function mine.started() arrange() end
function mine.toggle() modes.use("mine") end

modes.register("mine", mine)
sol.bind("super+y", mine.toggle)

return mine

Seven things in there are the conventions rather than the content.

Register with modes, do not keep your own on/off flag. A layout is a choice of one. Two layouts both placing every window means the second one to run wins and the arrangement looks like whichever that happened to be — and switching away from one leaves its windows where it put them. modes.use(name) turns the others off, calls their stopped, lets every window out of its tile, and calls your started. Calling it with the mode already current falls back to floating, so one key toggles.

Guard on active. Your handlers stay registered when your mode is off.

sol.animate before the batch, not per window. It applies to everything queued after it, so every window in one arrangement moves over the same interval on the same clock. Setting it per window is how an arrangement ends up looking like several separate animations that happen to overlap.

Only the desk in view. sol.windows() lists the windows of every workspace; workspaces.visible keeps the ones on the desk their monitor is showing. The others stay where they were last placed, and are carried a screen away by the workspace’s own selection, so a slot given to one of them is a hole on the desk you are looking at.

One monitor at a time. monitors.each hands you each screen with its own windows, and each.monitor is that screen’s work area. Laying everything out against sol.monitor() piles every screen’s windows onto the one the pointer is on.

Skip what is leaving, and leave dialogs to dialogs. A window being closed is still in the snapshot, leaving = true — close’s own snapshot included — and a layout that places it keeps room for a window that is going. A modal dialog belongs over its parent, and dialogs.place puts it there, on the parent’s screen, once every arrangement has been decided.

Most recently raised first. sol.windows() is topmost-first, which is the order a hit test wants. That is not the same as newest: clicking an older window raises it, and it becomes windows[1].

Modes that are not layouts

overview.lua is worth reading as the other shape a mode takes: it grabs input, transforms every window onto a grid with present, and clears them on the way out. It never calls place, so the layout underneath is untouched and leaving is exact.

It binds escape on the way in and takes it away with sol.unbind on the way out, because a bound key never reaches the application with the keyboard: bound for good, Escape was taken from every window (#174). Whether it is up is kept with sol.keep, because the grab and the thumbnails outlive super+shift+r, so a reload with the overview open binds Escape again, and Escape still leaves it. A mode that needs a key only while it is up should do the same.

That file is also the architecture’s proof, and its comment says so — the app switcher is that with a row instead of a grid, peek is it with one window at the cursor, and the icon-to-window genie is it with a dock icon named as the thing the window comes out of. If any of those ever needs new Rust, the transform layer is missing something.

Worth knowing

solium --check prints every binding it registered and will tell you an edit dropped one. super+shift+r reloads while the session runs, so a mode can be written against a desktop you are using.

A configuration that fails to load is reported with the file and the line, and the running session keeps whatever it already had. A typo costs a line of output rather than your windows.

See also: animation.md for how the feel is configured, ricing.md for the settings a mode should read rather than hardcode.

Animation

There is one animation engine and one clock. Every window that moves — a tiling rearrangement, entering overview, a window opening or closing, the genie super+m plays — moves through the same code. That is not tidiness: it is why modes cannot animate inconsistently with each other, which is the usual way a desktop ends up feeling assembled rather than designed.

The shape of it

An animation is a duration and a curve, and it is set on the batch rather than on the window:

sol.animate({ duration = 240, easing = "outCubic" })
sol.place(a.id, slot_a)
sol.place(b.id, slot_b)
sol.place(c.id, slot_c)

Everything queued after sol.animate uses it. Three windows rearranging is one movement over one interval on one clock — set the feel per window and you get three animations that happen to overlap, which looks like it.

duration is milliseconds. Leave either key out and that half keeps what it had.

Curves

Sampled from the engine itself rather than drawn to illustrate it, so what is plotted is what runs: each panel is Curve::at from crates/animation, for every curve Curve::all() names. The five with a fixed shape are drawn over 400 ms, and would take whatever duration they are given; the spring is drawn over the 445 ms Spring::settle_time says it takes, which for a spring is an outcome rather than a setting. dev/preview plots any of them live from the same engine, one at a time, and is the thing to reach for.

Six have names:

name
linearno easing; for things that should look mechanical
outCubicfast start, soft landing — the default, and right most of the time
outBackovershoots slightly and settles back; for things appearing
inOutQuadslow at both ends; for things that move rather than appear
inOutCubicthe same, harder: a longer wind-up and settle, which reads as weight
springphysical, and settles when it settles rather than on a schedule

The distinction between outBack and inOutQuad is the one worth internalising. A window arriving wants a little overshoot — it reads as landing. A window travelling from one place to another wants easing at both ends — overshoot on something that was already on screen reads as a wobble.

inOutCubic is inOutQuad with more of both ends: it holds back longer at each and crosses the middle faster. Reach for it when the distance is the point — the super+m genie pulls a window across most of the screen into a strip at its bottom edge, and wants to feel like it — and for inOutQuad when a window is only moving between two slots.

spring ignores duration entirely; a spring arrives when it arrives. Its parameters (stiffness, damping, mass, initial velocity) are not settable from Lua yet — you get the default, which settles in a little under half a second (445 ms) with a small overshoot. A gesture’s throw speed belongs in its initial velocity and that is why the parameter exists; wiring it to a real gesture belongs to the touch and gestures epic, #7, after v0.1.0.

Any curve at all

Four numbers instead of a name, and they are the two control points of a cubic bezier from (0,0) to (1,1):

sol.animate({ duration = 220, easing = { 0.34, 1.56, 0.64, 1 } })

These are exactly the four numbers CSS calls cubic-bezier, so a feel you found on any easing site transfers directly by pasting it. The named curves above are the ones worth a name; this exists so that a feel nobody anticipated does not need a compositor release.

y may leave 0..1 — that is what overshoot is. x is clamped to 0..1, because x outside that is not a curve a clock can walk along: time would have to run backwards to reach it.

Give it something that is neither a name nor four numbers and it warns and keeps the default rather than failing. Give it an unknown name and the same. A wrong easing should cost you a line in the log, not the session.

Where the feel actually lives

Almost none of it should be in your mode. config.lua declares it, and your mode reads it:

tiling = {
    motion = { duration = 240, easing = "outCubic" },
    -- The shorter feel for a window snapping back after a drag.
    snap = { duration = 180, easing = "outCubic" },
},
scrolling = {
    motion = { duration = 260, easing = "outCubic" },
    snap = { duration = 200, easing = "outCubic" },
},
workspaces = { motion = { duration = 300, easing = "outCubic" } },
open      = { motion = { duration = 220, easing = "outBack" }, scale = 0.88 },
loading   = { fade = 180 },
fullscreen = { animate = { duration = 260, easing = "outCubic" }, instant = { app_id = {} } },
maximize   = { animate = { duration = 220, easing = "outCubic" }, instant = { app_id = {} } },

These are the numbers config.lua really has. The block used to print open as 200 / outCubic / 0.92 and to carry a dock = { morph = ... } line, and #117 deleted both from config.lua for the same reason: nothing read either of them. open.lua held 220 / outBack / 0.88 as local constants, so the advertised numbers described an animation nobody had ever seen, and the script’s won when the two were wired together — those are what the animation was tuned against. There is no dock, so dock.morph configured a component that does not exist, and --check now reports that spelling as an unrecognised key.

So changing how tiling feels is one line in ~/.config/solium/user.lua:

return { tiling = { motion = { duration = 160, easing = "outBack" } } }

A mode that hardcodes its own durations is a mode nobody can tune, which is the whole reason these are here rather than in the Lua that uses them. tiling.lua takes config.tiling.motion and passes it straight to sol.animate; that is the pattern to copy.

Two of them are worth explaining because the reason for the number is not obvious:

  • snap is shorter than motion. A window returning after a drag is answering something you just did with your hand, and a long animation there reads as lag rather than polish.
  • open overshoots, and is the only one that does. outBack goes past the window’s real size and settles back, which is why it starts at 0.88 rather than nearer 1 — a small overshoot needs somewhere to have come from.

Closing

A window you close shrinks to 0.86 of its size and fades out over 190 ms, on inOutQuad (present::CLOSING). The compositor plays that before it tells the application to close, so the window can still paint while it goes, and by default the layout moves the other windows into its place at once (reflow_on_close; modes.md has the events). A window whose own application closes it fades the same way, from the last picture it drew (#126), and so does a loading window whose application never arrives, once loading.patience runs out.

An application can refuse: by saying nothing for a second after it is asked, or by answering with a dialog. Then the window comes back. A layout that puts it back moves it over the layout’s own duration; if nothing places it, it fades back in where it vanished over 150 ms, on outCubic.

None of these numbers is in config.lua yet, so none can be tuned: they are the exception to the rule above, and so are overview’s 260 and 200 ms, which live in overview.lua.

Animations you should not write

Some things must not be animated, and the reasons are worth knowing before you try:

A resize you are dragging. input/resize.rs says it in a comment: the window has to be under the pointer’s corner this frame. An animation puts it where the pointer was a hundred milliseconds ago, which reads as lag rather than as polish. tiling.lua passes { duration = 0 } while a seam is being dragged for exactly this reason. Animate motion the user did not personally drag.

A locked pointer. Nothing to animate — the cursor does not move at all, which is the point of a lock.

A scene fading itself out. The dissolve from a loading window to its application is the compositor’s, not the scene’s. Whether a window is see-through is a presentation transform, like where it is and how big, so the compositor applies it to the scene’s picture as it draws it. On the software renderer a scene could not do it anyway: Qt repaints only what it thinks changed, onto the pixels already there, so each half-transparent frame lands on its own opaque previous one and nothing fades. loading.fade is the setting.

What it costs

Drawing is damage-driven: a still screen draws nothing at all. An animation therefore has to ask for the next frame, and everything in the engine does — but it means an animation that never ends is a compositor that never stops drawing. The pulse pane style animates continuously while its window is focused, and keeps the compositor drawing for as long as it does. That is a fair trade you are making knowingly; making it by accident is not.

The clock is read fresh rather than sampled once per frame, and there is a test asserting so. That is not an optimisation, it is a correctness property: a cached clock froze whenever the screen was still, so a binding pressed after a quiet minute started its animation a minute in the past and was already finished by the time anything drew it. Animations that work only if you have been doing something are worse than none.

QML runs on the same clock. An animation in a pane style, a sol.surface scene or a hosted shell advances on the compositor’s clock, which each frame brings up to date before Qt’s events are delivered, so it cannot drift against the window transforms beside it. Between frames, the compositor’s own event loop serves Qt’s timers and the descriptors Qt waits on, on that same clock. So a QML Timer fires on time on an idle desktop, and costs a frame only when it changes what the scene draws (#164). The keyboard pill’s timed hide is such a Timer.

See also: modes.md for what to animate, ricing.md for the settings file.

Decorations, and other things the compositor draws

Most of what the compositor draws that is not a client’s window is QML, hosted in-process: the window frames, the loading window, the wallpaper, a shell you name in the configuration, any other scene a script declares, and the pointer when a scene of your own is named for it or no cursor theme is set (see shell-boundary.md for the shell). There is nothing to compile and no Rust to touch: write a file, name it, press super+shift+r.

Two things on screen are not QML: a pointer from an XCursor theme, which is the theme’s own picture, and the rounded corners cut into a client, which are a fragment program the compositor runs over the client’s pixels.

The property-by-property contract for a frame lives next to the styles, at crates/solium/qml/panes/README.md, and so does the one list of the styles that ship. This is the guide: what the pieces are, how to write one, and what it costs.

Three terminals tiled, in the default top style over the default wallpaper: the focused window’s titlebar is white, the other two light grey. Below, the same three in overview. Taken under the light theme Solium shipped until 2026-10-04 and not retaken yet: the shipped theme is now dark grey, the focused titlebar a shade lighter than the others (Colours and fonts). Both halves are the same QML. In overview each frame scales with the window it belongs to rather than being redrawn at a new size, because a frame is part of the window as far as the transform layer is concerned, which is why a mode can scale a window at all without knowing what a titlebar is.

What is QML, and where it comes from

whatshipped asnamed by
window framesqml/panes/<name>/pane = "top"
the loading windowqml/loading/*.qmlloading = { scene = "window" }
the wallpaperqml/wallpaper.qmlwallpaper = ... (see ricing.md)
a shell (bar, dock, launcher)nothing shipsshell = { scene = ... }
the keyboard pill, on a surface of its ownqml/indicator/keyboard.qmlkeyboard.indicator in config.lua (lua/keyboard_indicator.lua declares it through sol.surface)
any other sceneqml/tweaks.qml, the Developer Tweaks panel (--debug-mode only)sol.surface(name, { scene = ... })
the pointer, with no scene and no cursor themeqml/cursor.qmlnot replaced; name a scene instead
the pointer’s scenenothing shipscursor = { scene = ... }, or SOLIUM_QML_CURSOR for one run

Your own directory is ~/.config/solium/qml/, and for everything in that table but the pointer it is searched first. A file you write shadows the shipped one of the same name, and everything you did not write still comes from the shipped set, including its later improvements. A folder under panes/ counts only if it has a Pane.qml in it, so an empty one does not replace the shipped style of that name. There is no registry: nothing has to be listed anywhere for a style to be found.

The shipped pointer is the exception: ~/.config/solium/qml/cursor.qml does not replace it. A pointer of your own is a scene you name, which a bare name finds in that directory first. The pointer below says how.

Writing a pane style

A style is a folder. Pane.qml is its manifest: what the frame reserves from the client, and a list of layers. Each layer is its own QML scene, drawn at its own depth: behind the client, in the frame (over the client), or above the frame. panes/border/ is the smallest, and the one to read first:

// panes/border/Pane.qml
import QtQuick
import Solium

PaneStyle {
    insets.top: 3
    insets.right: 3
    insets.bottom: 3
    insets.left: 3

    requires: []

    Layer { depth: "frame"; name: "border"; source: "Frame.qml" }
    KeyboardPillLayer {}
}

The last line is the keyboard pill every shipped style draws at the focused text field’s caret; leave it out and the style draws none (the panes README, “The keyboard pill”).

// panes/border/Frame.qml, shortened
import QtQuick
import Solium

Item {
    id: frame

    property int insetTop: 0         // set by the compositor, from Pane.qml
    property bool focused: false     // set by the compositor

    Rectangle {
        anchors.fill: parent
        color: "transparent"         // the client shows through
        border {
            width: frame.insetTop
            color: frame.focused ? Theme.accent : Theme.edgeInactive
        }
        Behavior on border.color { ColorAnimation { duration: Theme.normal } }
    }
}

Five things about that are the whole model.

A layer’s root covers the window’s entire outer rect, frame and client together, not a strip at the top. Whatever it does not paint stays transparent and the client shows through. That is why a bar can be on any side, or there can be no bar at all. A layer that declares bleed gets a larger canvas, the window grown by that much, and bleedLeft and bleedTop say where the window is inside it.

The insets are what the style reserves, declared once on the manifest, and the compositor hands the same four numbers to every layer as insetTop, insetRight, insetBottom and insetLeft. Reserve nothing and the style becomes an overlay: it draws over the client and never moves it.

focused, pointerInside, title and the sizes are written for you when they change, so reacting to focus or to the cursor is a binding rather than a subscription. pointerInside is true while the pointer is anywhere over the window, including over the client, which is how a border can follow a cursor that is over a text editor. So are caret, where the focused text field’s caret is when this window has it, and values, whatever the configuration handed every pane with sol.pane_values{ ... }; the Keyboard singleton is there to read as well. The panes README says what each holds.

action is how a button asks for something. Set it to "close" or "maximize"; the compositor takes it and clears it, so a press is acted on once, on release.

onButton is how a button keeps a press from dragging the window. A press on the frame starts a window move unless a layer says the pointer is over a button, and the property the compositor reads for that is a boolean called onButton. The shipped top style keeps it next to the name of the button under the pointer:

property string hovered: ""                       // which button, or ""
readonly property bool onButton: hovered !== ""   // what the compositor reads

MouseArea {
    anchors.fill: parent
    hoverEnabled: true
    onContainsMouseChanged: frame.hovered = containsMouse ? "close" : ""
    onClicked: frame.action = "close"
}

A layer whose buttons do not declare onButton has its buttons drag the window instead of pressing, which looks like broken buttons rather than like a missing property. hovered on its own is not read by anything. Five of the shipped styles with buttons, left, bottom, pulse, reactive and reveal, declare hovered and not onButton, so their buttons may not press (#158). top is the one to copy.

Two more things a style author meets. Presses reach a layer only inside the band the insets reserve; hover goes everywhere, presses do not, so a button drawn over the client or out in the bleed cannot be clicked. And a tiled window can be small: the shipped bars hide the title, then their buttons, when there is no room for them (#133), and a style of yours should plan for a small paneWidth too.

The full list of what a layer is told and what is read back, depth and bleed in detail, and client.radius for rounding the client itself, are in the panes README.

One QML file with an Item at its root is still a style, called a decoration: put it in ~/.config/solium/qml/decorations/ and name it. It is one frame layer, and it declares its own insetTop and the rest, because it has no manifest. A bundle is found first, so a file there named like a shipped style (top.qml) never draws; give it a name of its own.

Sizes are logical, always

A frame is laid out in logical pixels and rasterised at whatever its monitor’s scale is. insets.top: 3 is three logical pixels on a 1x screen and six real ones on a 2x screen, and Theme.fontSize behaves the same way.

Nothing is required of you for that to work, and that is the point: there is no scale property to read and no arithmetic to do. It is worth knowing only so that you do not try. A frame that multiplied its own sizes by a scale would be twice as big as it asked to be, and a frame that hardcoded device pixels would be a different size on each monitor.

The compositor rasterises the scene at logical × scale and hands Qt the ratio between the two, so a titlebar on a HiDPI screen gets more detail rather than more blur. Test it with SOLIUM_OUTPUTS=2 and a scale on one of them; see dev/README.md.

Colours and fonts

Nothing in a frame should contain a hex code. Solium.Theme has nineteen properties:

  • colours: surface, surfaceInactive, edge, edgeInactive, text, textDim, control, controlInactive, accent, warning, danger, accentInk (what is drawn on the last three);
  • metrics: titlebarHeight, gap, margin;
  • type: fontFamily, fontSize;
  • chrome durations, in milliseconds: quick, normal.

The frames, the loading window, the keyboard pill and a hosted shell that imports Solium all read that one singleton, so a colour changed there changes all of them.

The shipped colours are greys, every one: red, green and blue the same. That is the maintainer’s decision for now: a dark theme in black, greys and white, with no accent hue and none of the logo’s or the wallpaper’s colours. The bars are near-black, the focused one a shade lighter, with light grey titles, dimmer on the windows without the keyboard; accent is a light grey, which the keyboard pill’s capsule is drawn in; and warning and danger, which the maximise and close buttons turn under the pointer, are two greys told apart by shade and by the glyph each then shows. A light scheme, or colour accents, would be a change of the values only: the names stay. qml::hosting_tests::every_colour_the_theme_publishes_is_a_grey holds the shipped file to it. And a grey drawn see-through over the wallpaper takes the wallpaper’s colour, so the bars of top, reactive and reveal, pulse’s breathing line, proximity’s border and the Developer Tweaks panel are opaque (tests/scenarios/pane-top-drawn.lua, tests/scenarios/pane-bars-opaque.lua, tests/scenarios/pane-pulse-drawn.lua, tests/scenarios/pane-proximity-drawn.lua, qml::hosted::tests::the_tweaks_panel_is_opaque_and_grey).

The fallback pointer and the default wallpaper do not read it; their colours are fixed. A copy of Solium/Theme.qml in ~/.config/solium/qml/Solium/ is meant to be how you change it, and does not work yet: the shipped module is found first (#88). A frame that hardcodes a colour is a frame that stops matching the moment anyone changes anything.

The pointer

With no scene of your own named for it, the pointer is the machine’s XCursor theme whenever there is one:

cursor = { theme = "Adwaita", size = 24 },

theme is a directory under ~/.icons, ~/.local/share/icons or /usr/share/icons. size is in logical pixels, 8 to 256, and is multiplied by each monitor’s scale. With either left out, XCURSOR_THEME and XCURSOR_SIZE decide, and then 24 pixels and no theme. A reload applies a change. An application can name the shape it wants, an I-beam or a resize arrow, through cursor-shape, and gets it from the theme; a shape the theme lacks falls back to that theme’s own arrow. Over a frame the compositor sets the pointer itself: the arrow over a titlebar, and the matching resize arrow on each edge.

Solium's own pointer, magnified eight times, over the dark wallpaper and over a white titlebar

Solium’s own pointer at 24 pixels, magnified eight times: over the dark part of the wallpaper, and over a focused top titlebar as it was under the earlier light theme. It is what you get with no scene and no theme set anywhere, or with a theme named that is not installed, and it is QML: qml/cursor.qml. Its colours are fixed rather than taken from Theme, a white body with a dark outline, because the pointer sits on whatever a client drew and has to stay legible on black and on white alike.

It is one arrow for every shape. A pointer of your own is a scene, named the way a shell’s is, cursor = { scene = "~/.config/solium/cursor/Cursor.qml" }, or SOLIUM_QML_CURSOR=<file> for one run. It draws every shape ahead of any theme, is told which one through Solium.cursor.shape, can be larger than size and can animate; a reload builds it again after an edit. ricing.md has an example, and shell-boundary.md what it is given.

Which windows get a frame, and what a failure looks like

Every window is offered a frame drawn by the compositor. A client that insists on drawing its own frame gets to, and is left without one; so are menus and tooltips, and a fullscreen window. pane = "none" (or SOLIUM_PANE=none) draws no frame at all and builds no QML scene per window.

A style that cannot be loaded (a name that is nowhere, a layer whose QML does not build, or a requires this session cannot meet) leaves each window it was for bare: no frame and no space reserved for one, and a line in the log saying so (#90). Fixing the file and pressing super+shift+r frames the windows opened after that; a window already left bare stays bare until it is opened again.

What it costs

Which path QML renders on decides most of this. On the hardware it is the GPU by default, when a trial render at startup passes. Nested it is software, and so it is with qml.renderer = "software", when the trial fails, or when the last GPU start in the compositor did not work. The log line that begins QML renderer: says which one a session got, and why.

On both paths, an idle layer costs a flag read: Qt is asked each frame whether the scene has anything new, and the compositor draws only then. Bleed is paid for in full, because every pixel of the larger canvas is drawn each time the layer changes, so a bar throwing spikes upward should ask for bleed: { "top": 48 } rather than 48.

A layer that is usually empty, as the keyboard pill’s is, can bind dormant to “nothing to show”. While it is true the layer is not drawn and nothing of it is blended over the client; in software it also lets go of its image, and on the GPU it keeps the buffer it was last drawn into. The panes README, “What it costs”, has the details.

On the GPU, each layer is drawn by Qt’s OpenGL scene graph straight into a buffer the compositor allocated, and nothing is copied or uploaded. A window being resized moves its layers onto a new buffer rather than rebuilding them, so their animations carry on through it.

In software, Qt rasterises a changed layer on the CPU, and the compositor copies it into a buffer and uploads it. A frame layer that paints only inside its own insets has only those bands copied, and the upload is the one box around them: for a titlebar that is about 4% of the window, and for a style that reserves all four sides it is the whole window. A layer that reserves space and paints outside it has to say so with property bool overlay: true, or what it paints outside is not copied. A layer at behind or above, a layer with any bleed, and every layer of a style that reserves nothing are overlays already.

requires: ["gpu"] is for a style that needs the GPU path. The software scene graph does not implement ShaderEffect, and Canvas does not appear to paint on it, so a style using either would draw wrong in software without a word. Declaring it means that on a software session, nested ones included, the style is refused and its windows are left bare, with the unmet term in the log. gpu is the only term there is, and a term this build has never heard of is refused too.

Animation inside a frame

Nothing to declare: a transition runs at the screen’s refresh rate, and a loop with a pause in it survives the pause.

A layer that never stops animating never stops costing anything. The one measurement there is dates from 2026-09-06, on the software path before the GPU one existed: with the pulse style animating at the screen’s full rate, the whole compositor used about a tenth of a core. pulse runs its animation only while its window is focused, so an unfocused window costs nothing. Making that trade knowingly is fine; making it by accident is not.

QML animations run on the compositor’s clock rather than Qt’s own timer, so a titlebar easing in step with its window stays in step.

The loading window

The other scene worth writing. It is what is inside a window between the user asking for an application and the application existing, and the window is already real by then: it has its slot, the other windows have moved aside, it can be closed.

It is handed program and waited. The one that ships uses only the name, on the grounds that the window already is the window, and the one fact you do not otherwise have is which application you are waiting for.

import QtQuick
import Solium

Item {
    id: card
    property string program: ""
    property int waited: 0

    Rectangle {
        anchors.fill: parent
        color: Theme.surface
        Text {
            anchors.centerIn: parent
            text: card.program
            color: Theme.text
            font { pixelSize: Theme.fontSize + 8; family: Theme.fontFamily }
        }
    }
}

The rest of loading in config.lua decides what the wait looks like: patience (how long before a window whose application never came is given up), reserves_a_slot (whether the others move aside at once), decorated (whether its frame is drawn meanwhile, which gives you a close button for an application that is not coming) and fade.

Do not fade it out yourself when the application arrives. The compositor does that, over loading.fade. Whether a window is see-through is a presentation transform, like where it is and how big, so the compositor applies it to the scene’s picture as it draws it. On the software renderer a scene could not do it anyway: Qt repaints only what it thinks changed, onto the pixels already there, so each half-transparent frame lands on its own opaque previous one and nothing fades.

Trying things quickly

SOLIUM_PANE=left       ./target/debug/solium     # one run, one pane style
SOLIUM_LOADING=mine    ./target/debug/solium     # one run, one loading scene
solium --check-qml path/to/thing.qml             # does it even load?

--check-qml loads one file and prints ok and its path, or what Qt reported, without starting a compositor, and exits with 1 when anything did not load. On a Pane.qml it loads every layer the style names in source: too, and a broken one fails the check by name. It loads in software, so it cannot build a requires: ["gpu"] style: it says so and leaves that style’s layers unloaded, which is not a failure. --check <file.qml> is the same check. Porting a scene means walking a chain of “type X unavailable” errors, and doing that through a real session costs ten seconds a link.

super+shift+r in a running session reads the configuration again, drops the QML cache and rebuilds every frame. Windows keep their slots and each client is resized to whatever the new style left it, so a frame can be written against a desktop you are using. While SOLIUM_PANE is set, it decides the style and pane = does not.

With --debug-mode, the Developer Tweaks panel is shown at startup and super+shift+d hides and shows it. It lists every style it finds, yours included, under “Pane style” (bundles) and “Decoration” (single files), and switches the style live. It also has presentation effects for the focused window and a reload button.

What ships

The shipped styles, and the demonstrations kept with the tests, are listed once, in the panes README. top is the default.

See also: ricing.md for the settings, animation.md for the engine that moves the windows these are drawn around, shell-boundary.md for what belongs to a shell rather than to the compositor.

Where Solium ends and a shell begins

Solium is the compositor. A shell — the bar, the dock, the launcher — is a project of its own, in its own repository, and it runs inside Solium as its configuration: its QML is hosted in the compositor’s own engine, beside the window frames, the pointer and every other scene the compositor draws. That is the line, and it is not where a Wayland tutorial would put it.

Where it is today:

  • A shell is named in the configuration, shell = { scene = "<its root QML file>" }, and hosted in-process through sol.surface, by lua/shell.lua. The shipped configuration names none.
  • A shell is QML written against Solium’s own API: import Solium for Theme, Keyboard, Monitors, Windows, WindowList, Workspaces, WorkspaceList, Grab and the attached Solium object (Solium.monitor, Solium.input, Solium.surface.reserve, Solium.keyboard, Solium.status), and Solium.send for what it asks Lua to do (below). Quickshell support was removed (#172); Quickshell itself may add Solium support on its own side.
  • The pointer can be a scene of its own the same way, cursor = { scene = "<its QML file>" }, given what the pointer is doing as Solium.cursor (below).
  • A shell that runs as its own program — Waybar, a Quickshell instance run on its own, any wlr-layer-shell panel — is supported too, as an ordinary client. It needs nothing from the configuration and gets nothing from the compositor’s engine.

The rest of this file is why hosting is the design, how to host one, and exactly what a hosted shell is given and what it is not.

The requirement that decides the architecture

An object should be able to move from the dock into a window’s titlebar.

Not “look similar in both”. Move — one object, travelling, unbroken.

That single requirement rules out the conventional answer. If the shell is a separate process painting its own pixels, then a dock icon and a titlebar are two scene graphs in two processes with two stylesheets, and an object cannot cross between them; the best available is a fake — dissolve one, appear in the other — which is exactly the kind of thing this project exists not to build.

The same requirement, in a weaker form, rules it out again: “the shell changes colour, so the decorations change too” is trivial when there is one theme object, and a synchronisation problem forever when there are two.

So: one engine

The compositor hosts one QML engine. Window decorations are scenes in it, and so is a hosted shell’s bar, dock and launcher. A scene that imports Solium gets the same Solium.Theme singleton the frames read, because there is only one of it.

That gives, in order of how hard they would otherwise be:

  • One design system. Solium.Theme (crates/solium/qml/Solium/Theme.qml) is what the window frames and the loading window are drawn with, and any hosted scene that imports Solium can read it. Changing a colour there changes the titlebars and a shell that uses it together, with no rebuild, because they read the same object and not two copies. A shell brought in with a theme of its own keeps its own until it is pointed at this one. Two of the compositor’s own scenes keep fixed colours instead: the fallback pointer, which has to stay legible over whatever a client drew, and the default wallpaper.
  • Objects that travel, planned. The aim is that an item can leave the dock and land in a titlebar, one object the whole way. None of it is built, and it will not be a reparent: every scene has a QQuickWindow of its own, so each is its own scene graph, and an item cannot move from one to another. The plan is a flight or a morph: a third, live instance of the same component, drawn over both ends while they are hidden and moved on the compositor’s clock. One engine is what lets that instance be the component itself, with the same theme, rather than a picture of it.
  • No protocol for shell geometry, planned. A hosted dock’s icon rectangles need not be published to the compositor, because the compositor’s engine already has them. The plan is for a scene to name an item as an anchor (Solium.region) and for an animation to aim at that name, read again on every frame, so a genie follows an icon while it moves and can never animate against a stale copy. Nothing of that is built yet; it is tracked under Later in #169. Today a genie aims at a window, a sol.surface scene or a fixed rectangle (modes.md). Only a dock that runs as its own program would need a protocol to hand its rectangles over.

This is the arrangement Apple has, and it is unavailable to anyone configuring an existing compositor. It is the reason for writing one.

Hosting a shell

Naming it

In ~/.config/solium/user.lua (or your own config.lua):

return {
    shell = { scene = "~/.config/solium/shell/shell.qml" },
}

~ is expanded. The scene is drawn on every monitor, one instance each, over the windows and across the whole monitor; each instance reads its own monitor as Solium.monitor. shell = { on = "primary" }, or a connector name, draws one instance instead (script::tests::the_shell_is_on_every_monitor_unless_the_configuration_names_one). It takes the pointer only where its items take input, as “Clickable only where it takes input” under What a hosted shell is given says; everywhere else the pointer goes to the windows under it. super+shift+r picks up a change to the setting, and tries again a scene that would not load, so a typo in the shell costs a reload rather than the session (state::tests::real_client::a_reload_tries_again_a_scene_that_would_not_load). Editing a file under the scene’s own directory rebuilds the scene with no reload at all: that is checked every half second while frames are being drawn. false, the default, hosts nothing, and taking the setting out takes the shell away on the next reload.

A reload does not rebuild a hosted scene. sol.surface declared again with the same scene file writes the properties that changed into the live scene, so an open popup, a running animation or a half-typed query survives super+shift+r; only a different scene file builds the scene again (scripted::tests::a_redeclared_property_is_written_into_the_live_scene).

SOLIUM_SHELL_SCENE=<file> overrides the setting for one run. It is how dev/run-shell.sh hosts a shell under development without touching the configuration you normally run; see dev/README.md.

Installing one next to your configuration

A shell is its own repository. Clone it beside your configuration and name its root file:

git clone <the shell's repository> ~/.config/solium/shell
shell = { scene = "~/.config/solium/shell/shell.qml" },

A shell that imports its own files by relative path needs nothing more.

solium --check-qml <file> loads one file without starting a compositor and prints ok or what Qt reported, which is the quick way through a chain of “type X unavailable” errors. It exits with 1 when the file does not load.

What a hosted shell is given

A canvas. One scene per monitor, each the size of its whole monitor, on the top layer: over the windows, under a layer-shell client’s own top layer surfaces, and covered by a fullscreen window unless fullscreen.covers says otherwise (scripted::tests::a_surface_on_every_monitor_has_one_live_scene_per_monitor). It gets pointer motion, every mouse button as itself, the wheel, and the modifiers held, so a MouseArea or a WheelHandler works as it does anywhere (qml::hosted::tests::a_right_press_reaches_a_mouse_area_as_the_right_button, qml::hosted::tests::the_wheel_reaches_a_wheel_handler_with_its_angle). Each event carries its time, so a double press is a double-click and a TapHandler counts its taps, by Qt’s own double-click interval and distance (qml::hosted::tests::a_double_press_on_a_mouse_area_is_one_double_click, qml::hosted::tests::a_tap_handler_counts_taps_by_when_they_happened, qml::hosted::tests::two_presses_further_apart_than_the_interval_are_two_single_clicks). The wheel with super held stays the compositor’s (state::tests::real_client::reflow_on_close::hosted::super_and_the_wheel_stay_the_compositors_over_a_scene), and while the session is locked none of it reaches the scene (state::tests::real_client::lock_focus::the_wheel_over_a_hosted_scene_is_not_the_scenes_while_locked, state::tests::real_client::lock_focus::a_button_let_go_behind_the_lock_is_not_held_after_it). A press the scene held when the session locked is cancelled, not clicked: a MouseArea, a Button or a TapHandler holding it hears canceled (state::tests::real_client::lock_focus::the_lock_lets_go_of_a_hosted_scenes_press_and_its_hover, qml::hosted::tests::a_press_let_go_of_unseen_is_cancelled_not_clicked). A monitor that arrives gets its instance there and then, and one that goes takes its instance with it (scripted::tests::an_instance_goes_with_its_monitor_and_comes_with_a_new_one). It reads where it is from Solium.monitor, below; screenInfo is gone.

Clickable only where it takes input. The compositor asks the live item tree under the pointer, so a point is the scene’s only where a visible, enabled item that is not fully transparent takes input: any item that accepts mouse buttons — a MouseArea, a Qt Quick Controls control such as a Button or a TextField, a TextInput or TextEdit, a Flickable (a ListView among them), a PathView, a MultiPointTouchArea — a pointer handler other than HoverHandler, such as a TapHandler or a DragHandler, a link in a Text with an onLinkActivated handler, or an item marked Solium.input: true. An item at opacity 0, itself or through an ancestor, takes nothing, though Qt would still deliver to it (qml::hosted::tests::the_item_tree_decides_what_a_point_claims). The rest of a Text takes no press, nor does a link nothing handles, and a plain label takes nothing at all (qml::hosted::tests::a_text_takes_a_press_only_on_a_link). Solium.input: "hover" takes the pointer’s motion and leaves presses to what is under it, which is how an edge strip reveals a hidden dock. A HoverHandler on an item that takes no press itself takes only the motion in the same way, and a press there goes to what is under it. Solium.input: false takes an item out. An item’s shape counts, through its containmentMask, so a rounded popup’s corners pass clicks through; a mask written in QML has to be typed, function contains(point: point): bool, or Qt ignores it, and it answers for the whole item, its bounds too. A disabled handler takes nothing (qml::hosted::tests::a_disabled_handler_claims_nothing), and an open Qt Quick Controls popup, a Popup, a Menu or a ComboBox’s list, takes the points it is drawn on, though Qt draws it in the window’s overlay rather than under the scene’s root (qml::hosted::tests::an_open_controls_popup_claims_its_press), and Solium.input written on the Popup is that item’s (qml::hosted::tests::solium_input_on_a_controls_popup_is_its_items). A popup is laid out against the whole scene, so a Menu opens where it is asked to (qml::hosted::tests::a_controls_popup_is_laid_out_against_the_whole_scene), and a modal one takes every point of its scene while it is open, as Qt’s own modality does (qml::hosted::tests::a_modal_popup_takes_every_point_of_its_scene_while_it_is_open). Everywhere else the window under the shell gets the press and the wheel (state::tests::real_client::reflow_on_close::hosted::the_wheel_where_the_shell_draws_nothing_is_the_windows_under_it), and where the scene takes a press, the window under it does not have the pointer, nor the keyboard when focus follows the mouse. A press the scene took is its until every button is up, wherever the pointer goes meanwhile, and the pointer keeps the shape it had at the press (state::tests::real_client::reflow_on_close::hosted::a_press_where_the_shell_draws_nothing_reaches_the_window_under_it, state::tests::real_client::reflow_on_close::hosted::a_release_after_dragging_off_a_shell_button_reaches_the_scene, state::tests::real_client::reflow_on_close::hosted::a_press_a_scene_holds_keeps_its_shape_over_a_resize_border, state::tests::real_client::reflow_on_close::hosted::focus_follows_the_mouse_through_a_shell_only_where_it_takes_no_press, qml::hosted::tests::the_item_tree_decides_what_a_point_claims).

Popups that hold the pointer. Grab { name: "tray-menu"; target: menu; active: menu.visible; onDismissed: menu.close() } holds the pointer for the scene while it is active (qml::hosted::tests::a_grab_is_held_while_active_and_dismissed_on_request): motion and the wheel go to it wherever the pointer is, and no window has the pointer, nor its frame, nor the keyboard when focus follows the mouse (state::tests::real_client::reflow_on_close::hosted::while_a_grab_is_held_the_pointer_is_the_scenes, state::tests::real_client::reflow_on_close::hosted::while_a_grab_is_held_the_wheel_is_the_scenes, state::tests::real_client::reflow_on_close::hosted::while_a_grab_is_held_no_window_takes_focus_frame_or_cursor_from_the_pointer). Its target is an item or a Qt Quick Controls Popup, a Menu among them; anything else has no points, so every press dismisses the grab, and the log says so (qml::hosted::tests::a_controls_popup_is_a_grabs_target). A press inside its target is the scene’s, even where no item there takes input (state::tests::real_client::reflow_on_close::hosted::a_press_inside_the_grab_target_reaches_the_scene). With more than one Grab of a scene active, a press inside any of their targets is the scene’s, and the newest one’s name is the one outside_click is looked up by (qml::hosted::tests::a_scenes_newest_grab_is_reported_and_every_active_one_counts). A press outside it dismisses it: every active Grab of the scene hears dismissed, newest first (qml::hosted::tests::a_scenes_newest_grab_is_reported_and_every_active_one_counts), and the press is then swallowed, its release with it, or passed on to whatever is under it when outside_click says "pass" (state::tests::real_client::reflow_on_close::hosted::a_press_outside_a_grab_dismisses_it_and_is_swallowed_by_default, state::tests::real_client::reflow_on_close::hosted::with_outside_click_pass_the_dismissing_press_reaches_the_window_under_it). However a grab ends, swallowed, passed, or let go of by its scene, the window under the pointer has the pointer back at once, or, when the scene holds a press as it ends, at that press’s release, so a click there with no motion before it reaches it (state::tests::real_client::reflow_on_close::hosted::a_swallowed_outside_press_gives_the_pointer_back_to_the_window_under_it, state::tests::real_client::reflow_on_close::hosted::a_popup_that_closes_by_itself_gives_the_pointer_back_to_the_window_under_it, state::tests::real_client::reflow_on_close::hosted::a_surface_taken_away_gives_the_pointer_back_to_the_window_under_it, state::tests::real_client::reflow_on_close::hosted::a_popup_closed_during_a_press_inside_it_gives_the_pointer_back_at_the_release). shell.outside_click in config.lua is the hosted shell’s, "swallow" by default, and a table names grabs, { default = "swallow", ["tray-menu"] = "pass" } (script::tests::the_shell_takes_its_outside_click_from_the_configuration, state::tests::real_client::reflow_on_close::hosted::a_policy_named_for_the_grab_beats_the_default). Any other value, in the word or in the table, fails the configuration’s load (script::tests::an_unknown_outside_click_is_refused). One grab is held at a time, and one another scene takes dismisses it (state::tests::real_client::reflow_on_close::hosted::a_grab_another_scene_takes_dismisses_the_one_held). A surface the pointer does not reach, declared interactive = false, holds no grab, and its scene’s grabs dismiss none (state::tests::real_client::reflow_on_close::hosted::a_surface_the_pointer_does_not_reach_holds_no_grab); one declared so while it holds a grab hears it dismissed (state::tests::real_client::reflow_on_close::hosted::a_surface_declared_again_out_of_the_pointers_reach_dismisses_the_grab_it_held). A press the scene already held when its grab began keeps the pointer until its release, so a button whose press opens a menu is let go of as usual (state::tests::real_client::reflow_on_close::hosted::a_press_held_when_a_grab_begins_keeps_the_pointer_until_its_release). A game’s pointer lock is let go while a grab is held, at once even while a press of the game’s own still keeps the pointer on it, and comes back after it (state::tests::real_client::reflow_on_close::hosted::a_grab_suspends_a_pointer_lock_and_the_lock_comes_back_after, state::tests::real_client::reflow_on_close::hosted::a_grab_begun_during_a_press_on_a_locked_window_lets_go_of_the_lock_at_once), and locking the session dismisses it (state::tests::real_client::reflow_on_close::hosted::locking_the_session_dismisses_a_hosted_grab). No grab is held behind the lock; one a scene takes there is held once the lock is gone (state::tests::real_client::lock_focus::a_grab_a_scene_takes_behind_the_lock_is_held_once_it_is_gone).

The keyboard, when an item asks. TextField { Solium.keyboard.wants: activeFocus; Solium.keyboard.claims: [ "Escape", "Return", "Up", "Down" ] } takes the keyboard while it is visible and wants it (qml::hosted::tests::a_field_that_wants_the_keyboard_reports_its_claims, qml::hosted::tests::an_invisible_field_does_not_hold_the_keyboard). When more than one visible item of a scene wants it, the one with active focus holds it, else the one that came to want it last, and only that item’s claims count (qml::hosted::tests::the_holder_is_the_focused_wanting_item_else_the_one_that_wanted_last). Every hosted scene’s window is active from the start, so focus: true gives a field activeFocus; bind wants to it, because the compositor takes the keyboard back by taking that focus away. Whatever wants is bound to, a scene the keyboard was taken back from does not take it again until an item asks anew: it comes to want it, is shown again, or takes active focus again (qml::hosted::tests::a_scene_let_go_of_takes_the_keyboard_again_only_when_asked_anew). Written on a container around the field, wants works too: the field inside loses its focus when the keyboard is taken back, and taking it again asks anew for the container (qml::hosted::tests::a_let_go_takes_the_focus_from_a_field_inside_the_container_that_wants_the_keyboard). Written on a Qt Quick Controls Popup, which is no item, Solium.keyboard is the popup’s own, its activeFocus and its being open, so a search popup is Popup { focus: true; Solium.keyboard.wants: activeFocus; TextField { focus: true } } (qml::hosted::tests::a_field_in_a_popup_that_wants_the_keyboard_takes_the_keys); on anything that is neither an item nor a Popup it holds nothing, and the log says so. Such a popup holds the pointer with a Grab and the keyboard at once: a press outside it dismisses it and the window under the pointer has the pointer back, and the keyboard once the field lets go (state::tests::real_client::reflow_on_close::hosted::a_search_popup_holding_the_pointer_and_the_keyboard_gives_both_back_when_dismissed). The window that had the keyboard loses it meanwhile, still reads as the focused window and is drawn focused, and gets it back when the field lets go (state::tests::real_client::reflow_on_close::hosted::the_window_gets_the_keyboard_back_when_the_shell_lets_go). Text comes from the compositor’s own keyboard state, so typing works with any layout active, Russian included (input::tests::russian_typed_through_the_compositor_reaches_a_hosted_text_field, input::tests::while_the_shell_holds_the_keyboard_russian_letters_reach_it_as_cyrillic), and with Control held a letter is named by the one your Latin layout has on that key, as Qt names it, so ctrl+a and ctrl+z work with Russian active (input::tests::ctrl_a_selects_all_in_a_hosted_text_field_on_russian), and a key held repeats at the keyboard’s own rate (input::tests::a_held_key_repeats_into_the_scene_at_the_keyboards_rate, input::tests::a_held_key_noticed_late_still_repeats_at_the_keyboards_rate), unless your keymap says it does not, as it says of a modifier, AltGr among them, and of a group toggle such as grp:alt_shift_toggle (input::tests::a_held_modifier_does_not_repeat_into_the_scene, input::tests::a_held_group_toggle_does_not_repeat_into_the_scene). A group toggle never repeats, even on a key that does, as grp:alt_space_toggle’s is (input::tests::a_held_group_toggle_on_space_does_not_repeat_into_the_scene). The compositor’s bindings keep working, super+q on Russian among them, except the keys the item claims, which are the field’s (input::tests::an_unclaimed_super_binding_still_fires_on_russian_while_the_shell_holds_the_keyboard, input::tests::a_claimed_key_reaches_the_scene_and_not_its_binding); a claim is spelled as sol.bind spells a key. shell.keyboard.bindings in config.lua is "except_claimed" by default, which is that: every binding but the claimed keys. "all" keeps every binding, claimed or not, and "none" gives the shell every key (input::tests::with_bindings_all_a_claimed_binding_wins, input::tests::with_bindings_none_even_super_bindings_reach_the_scene, script::tests::the_shell_takes_its_keyboard_bindings_from_the_configuration). Any other value fails the load too (script::tests::an_unknown_keyboard_bindings_is_refused), and a reload that changes it applies from the next key, while the shell holds the keyboard (state::tests::real_client::reflow_on_close::hosted::a_bindings_policy_redeclared_while_the_shell_holds_the_keyboard_applies_at_once). The Ctrl+Alt escapes always work (input::tests::the_escape_hatches_beat_a_shell_that_holds_the_keyboard). Clicking a window or sol.focus takes the keyboard back (state::tests::real_client::reflow_on_close::hosted::clicking_a_window_ends_the_shells_hold, state::tests::real_client::reflow_on_close::hosted::sol_focus_ends_the_shells_hold), and so does locking the session; no scene holds it behind the lock, and one that asks there has it once the lock is gone (state::tests::real_client::lock_focus::no_scene_holds_the_keyboard_while_the_session_is_locked). The pointer crossing a window does not take it back, even with focus following the mouse (state::tests::real_client::reflow_on_close::hosted::the_pointer_crossing_a_window_does_not_end_the_shells_hold). One scene holds it at a time (state::tests::real_client::reflow_on_close::hosted::a_hold_another_scene_takes_returns_to_the_window_the_first_took_it_from), and a scene whose monitor is unplugged gives it back (state::tests::real_client::reflow_on_close::hosted::a_monitor_unplugged_while_its_scene_holds_the_keyboard_gives_it_back). A surface the pointer does not reach, declared interactive = false, holds no keyboard, as it holds no grab, since nothing could click it to take the keyboard back; one declared so while its scene holds it gives it back (state::tests::real_client::reflow_on_close::hosted::a_surface_the_pointer_does_not_reach_holds_no_keyboard, state::tests::real_client::reflow_on_close::hosted::a_surface_declared_again_out_of_the_pointers_reach_gives_the_keyboard_back). The compositor’s own Qt loads no input method from your session, so IBus and the like do not run inside it (launch::tests::the_compositors_qt_takes_no_input_method_from_the_session); the programs it starts still get yours (launch::tests::a_spawned_program_gets_the_input_method_the_compositors_qt_does_not).

The compositor’s clock and frames. Its animations advance on the same clock as every window transform, a running animation asks for the next frame, and the scene is redrawn only when Qt says it changed. Qt is served between frames too: a Timer fires on time on an idle desktop, and what Qt waits on a descriptor for — a socket, an answer from another thread — arrives when it is ready, with no frame drawn unless the scene changed. A Timer is on that same clock, so one beside an animation nothing draws still fires.

The Solium QML module. Theme above all: the colours, fonts and metrics the frames are drawn with. Everything import Solium brings is written unqualified, as Theme is: the singletons Theme, Keyboard (“The keyboard, live”, below), Monitors (“Its monitor, live”, below), Windows (“Windows, live”, below) and Workspaces (“Workspaces, as the configuration has them”, below); the types Grab (“Popups that hold the pointer”, above), WindowList (“Windows, live”) and WorkspaceList (“Workspaces, as the configuration has them”); the pane-style types PaneStyle and Layer, and the keyboard pill’s KeyboardPill and KeyboardPillLayer (the panes README); and the attached Solium object, which any item can read, with exactly ten members: Solium.monitor, the monitor this instance of the scene is on (below); Solium.input, true, false or "hover" (above); Solium.surface.reserve.top, right, bottom and left (“Room of its own”, below); Solium.keyboard.wants and claims (above); Solium.status, the text sol.status set (“Workspaces, as the configuration has them”, below); Solium.send(action, data), what it asks Lua to do (below); Solium.cursor, the pointer, and the hotspot a pointer scene sets (What a pointer scene is given); and Solium.region, Solium.material and Solium.materialState, which every item accepts and nothing reads until materials exist (#199), materialState reading "off" till then (qml::hosted::tests::the_attached_type_shares_the_solium_uri_with_the_shipped_module, qml::pointer::tests::a_region_and_a_material_are_accepted_and_materials_are_off). Nothing else is public: Insets, ClientTreatment and ClientShadow are internal, and the row types have no name, so a shell’s own Monitor.qml is not shadowed (qml::hosted::tests::a_shell_file_named_like_a_row_is_still_the_shells), and Window is still Qt Quick’s in a scene that imports both (qml::hosted::tests::a_quick_window_is_still_qt_quicks_beside_the_windows_model), and a shell’s own Workspace.qml is its own (qml::hosted::tests::a_shell_file_named_like_a_workspace_is_still_the_shells). A shell’s own file named like a type or singleton the compositor registers (Grab, Keyboard, Monitors) is hidden by it: a Monitors.qml beside a scene is the Monitors model there, and a scene that creates one does not build, so name such a file otherwise (qml::hosted::tests::a_shell_file_named_like_a_singleton_is_hidden_by_it). A Theme.qml of your own in ~/.config/solium/qml/Solium/ is meant to override the shipped one, and does not yet: the shipped module is found first (#88). The attached Solium object and Grab belong to a hosted scene, which sol.surface builds on a monitor. Elsewhere (a pane’s layers, the loading window, the pointer’s scene, the fallback pointer) they build and do nothing: Solium.monitor is an absent row with an empty name and present: false, Solium.surface.reserve reserves nothing, and Solium.keyboard and a Grab hold nothing (qml::hosted::tests::a_scene_hosted_on_no_monitor_reads_an_absent_monitor, qml::hosted::tests::an_unhosted_scene_may_bind_a_reserve_and_reserves_nothing, qml::hosted::tests::an_unhosted_scene_may_bind_the_keyboard_and_holds_nothing). Solium.input changes nothing there either, since what reaches a pane’s layers is decided by the pane, not by the scene’s item tree. Theme and Keyboard are the same in every scene.

Its monitor, live. Solium.monitor is the row of the monitor this instance is on: name, whole and area (rectangles in the global space, area being the work area), scale, transform, primary, and present (valid reads the same). It changes in place, once per frame and all at once, when the monitor does, and a monitor that goes reads present: false and keeps its name; the same monitor coming back is the same row again (qml::hosted::tests::a_published_monitor_reaches_solium_monitor_in_its_scene). Until the compositor publishes a monitor, its row carries only the name, and present and valid read false (qml::hosted::tests::the_attached_type_shares_the_solium_uri_with_the_shipped_module). A scene that wants its own coordinates subtracts whole.x and whole.y. transform is spelled as sol.monitors() spells it: "normal", "_90", "_180", "_270", "flipped", "flipped90", "flipped180" or "flipped270", and not "90" or "flipped-90" as sol.monitors{ ... } takes it (models::monitors::tests::a_turned_monitor_row_names_its_transform_as_smithay_does). Monitors is every monitor, as a list model with the same roles and a count, and Monitors.get(name) one of them; a changed value is one dataChanged for that role alone (qml::hosted::tests::the_monitors_model_lists_every_row_and_changes_one_role_at_a_time). Rows also say what is reserved on each edge (top, right, bottom and left: what the work area lost there, layer-shell zones and hosted reserves together), whether the monitor’s power is "on" or "off", whether the pointer is on it and whether it is the active one, the monitor in front of you (models::monitors::tests::a_monitor_row_says_what_is_reserved_and_where_the_pointer_is, models::monitors::tests::on_two_monitors_only_the_pointers_is_active_and_each_says_its_own_edges). Monitors.get(name) of a monitor that is not there answers a row carrying only that name, which becomes the monitor when it is plugged in; one that goes leaves the list and keeps its last values, so read present before them (qml::hosted::tests::the_monitors_model_lists_every_row_and_changes_one_role_at_a_time). In a delegate of Monitors, scale and transform are the delegate item’s own properties, so read those two as model.scale and model.transform (qml::hosted::tests::a_monitors_delegate_reads_scale_and_transform_through_model). The rows are published with a frame, so while every monitor is off none is published, and power reads "off" only while another monitor is drawn.

Windows, live. Windows is every window, a list model built from the compositor’s own panes and updated in place once per frame: id, title, appId, pid, xwayland, monitor, workspace, focused, focusOrder (0 is the most recently focused, and a window that closes leaves no gap), urgent (it asked for attention nobody could see, until it is focused), fullscreen and maximized (as the compositor last set them, without waiting for the application), modal, parent, state (loading from the click, shown, closing) and onStage (state::tests::real_client::reflow_on_close::hosted::a_window_row_carries_where_it_lives_and_its_focus, state::tests::real_client::reflow_on_close::hosted::focus_order_is_most_recent_first, state::tests::real_client::reflow_on_close::hosted::a_closed_window_leaves_no_gap_in_focus_order, state::tests::real_client::reflow_on_close::keyboard_at_open::a_refused_activation_marks_the_window_urgent_until_it_is_focused, state::tests::real_client::reflow_on_close::keyboard_at_open::a_genuine_activation_of_a_window_its_own_frame_hides_does_not_keep_the_keyboard, state::tests::real_client::reflow_on_close::hosted::a_maximised_window_reads_maximized_at_once). In a delegate they are model.id, model.state and model.parent: id is QML’s own word, and the item’s own state and parent win over the roles (qml::hosted::tests::a_delegate_reads_id_state_and_parent_through_model). A window is listed from the moment it is launched, before its application draws, unless loading.reserves_a_slot is off; until then it has no pid, which reads -1 (state::tests::real_client::reflow_on_close::hosted::a_window_still_loading_is_listed_as_loading). An X11 window’s pid is the process Xwayland names for the window’s own X connection, -1 when it names none, and never Xwayland’s own (models::windows::tests::an_x11_window_is_never_given_the_pid_of_its_connection). workspace is the first workspace the configuration declared the window on (below), and reads "" until a declaration names it (state::tests::real_client::reflow_on_close::hosted::workspace_rows_count_their_windows_and_say_which_is_shown). It is the workspace’s id, not its <group>/<id> key, and a group’s ids are not unique across monitors (“2” exists in every group), so filtering on it without also filtering on monitor lists workspace 2 of every monitor. WindowList { monitor: Solium.monitor.name; sort: "mru" } is a filtered, sorted view, never reset: monitor, workspace, app and onStage filter, an empty one (or onStage left unset) keeping every window, and sort is "", the order windows opened, or "mru", the most recently focused first. A delegate on a WorkspaceList binding workspace: model.key matches nothing, since key is the <group>/<id> row key and workspace wants the id alone: WindowList { monitor: Solium.monitor.name; workspace: model.id }, or read the workspace row’s own windows list instead. Windows.focused is never null and follows focus, and with no window focused it reads present: false and is empty, not the window focused last (qml::hosted::tests::the_focused_facade_is_empty_with_nothing_focused); Windows.get(id) is one window’s row, which reads valid: false once the window has gone. It is a lookup with no change signal of its own, so a binding reads Windows.count beside it, as { Windows.count; return Windows.get(id) } does, to be asked again as windows come and go. Rows say where a window lives, not where it is drawn this frame (qml::hosted::tests::the_windows_model_filters_sorts_and_keeps_its_facades). A retired row (valid: false) is kept for 10 s after it leaves, then freed, and nothing marks count changed at that moment — only a window or workspace coming or going does. Read it again through Windows.get(id) (or the workspace equivalent) rather than holding the row itself past that grace period, or a reference kept in a var can outlive it.

Workspaces, as the configuration has them. The compositor does not know what a workspace is: Lua declares them with sol.workspaces, and the shipped workspaces.lua does so whenever they change (script::tests::the_shipped_workspaces_declare_what_each_monitor_shows, script::tests::a_window_that_opens_is_declared_on_the_workspace_its_monitor_shows). Workspaces is every workspace declared, joined with the windows: key (<group>/<id>), id, name, col, row, group, monitors, active (its group shows it), focused (shown by the group of the monitor in front of you), occupied (how many windows are on it), urgent and hasFullscreen (of a window on it), hidden and windows (state::tests::real_client::reflow_on_close::hosted::workspace_rows_count_their_windows_and_say_which_is_shown). WorkspaceList { monitor: Solium.monitor.name } is one monitor’s group, Workspaces.showing(name) what that monitor shows, Workspaces.current what the monitor in front of you shows, each empty, never null, while nothing is shown, and Workspaces.arrangement the declared shape, { kind, columns, rows }. A per-monitor bar binds showing(Solium.monitor.name): a singleton cannot know which instance asks. Solium.status is the text sol.status set, the same in every scene (qml::hosted::tests::the_workspaces_model_its_list_and_its_facades, models::tests::publish_models_carries_the_workspaces_the_status_and_the_arrangement). A workspaces widget switches with Solium.send("workspaces.go", { id: model.id, monitor: Solium.monitor.name }), and sends a window away with Solium.send("windows.send", { id: window.id, workspace: "2" }), both answered by workspaces.lua (script::tests::a_workspaces_go_from_a_scene_switches_the_monitor_it_names, script::tests::a_windows_send_from_a_scene_moves_the_window_it_names). Each takes that form only, a workspace being a whole number or its digits, and windows.send only for a window that is open; any other form, { id, monitor } or a workspace 2.5 among them, moves nothing and is logged (script::tests::a_workspaces_go_from_a_scene_switches_the_monitor_it_names, script::tests::a_windows_send_names_its_window_by_number_or_digits_and_logs_any_other_form). A declaration naming a monitor or a window the compositor does not have leaves it out, with any group left with no monitor, and so does one declaring a workspace twice among the groups that stay, keeping the first; the log names each once (models::workspaces::tests::a_declaration_naming_an_unknown_monitor_or_window_drops_them, models::workspaces::tests::an_unknown_monitor_or_window_is_logged_once_per_name, models::workspaces::tests::a_workspace_declared_twice_is_one_row, models::workspaces::tests::a_workspace_of_a_group_left_out_is_not_logged_as_declared_twice).

Installed applications, and their icons. Apps (03 §3.2.13) is every installed, visible application (NoDisplay and Hidden entries are left out at the source, not filtered in a scene), a row model like Windows and Workspaces: entries is itself (so ListView { model: Apps.entries } and ListView { model: Apps } are the same list), and Apps.get(id) a facade that is never null, with valid false for an id nothing installed answers – so a pinned app that was removed is a ghost, not a hole, the same Ruling 17 convention Windows and Workspaces already keep. Apps.ready is false until the first scan completes. A row: id (the desktop file id), name, genericName, icon (an image://solium/icon/... URL, ready for an Image), categories, keywords. A scene launches one with Solium.send("apps.launch", { id }), routed to the compositor’s existing spawn path (launch.rs) the same way windows.focus is; a running app is found by joining Windows’ appId to an Apps row’s id in the scene or in Lua, not by anything Apps itself tracks (no running, no windowCount here yet – qml/preview/Dock.qml is the one place in this tree that does that join, and its own file doc says why it is one WindowList per app rather than a single pass over Windows). sol.apps() is the same list, read from Lua, for config written against what is actually installed (lua/preview/dock.lua’s first-run pins) – but, like sol.windows() and sol.monitors(), only meaningfully inside a dispatch: a .lua file’s top level runs before the first real snapshot arrives, so sol.apps() there reads empty (lua/preview/dock.lua‘s own module doc works through this). image://solium/icon/<name>?size=48&scale=2 is the one provider behind Apps’ own icon and anything else a scene wants a theme icon for, by plain name (an absolute path is refused, not resolved – P1, icon.rs’s module doc). What is cut from this version of both: inotify (a scan is redone wholesale, synchronously, at startup and on a reload, not on every directory change); a cache in the icon provider (every request re-walks the theme chain); sol.icons{ theme = ... } (the theme is always GTK’s settings.ini, then Adwaita, then hicolor); SVG rasterises only where the Qt installation has the imageformats/libqsvg plugin, which this project’s own container image does not ship, so a PNG icon resolves everywhere this builds and an SVG-only one falls back to the default glyph there.

The desktop folder, live. Folder (04-ui.md §4.9) is every entry of Solium.dirs.desktop, a row model like Apps: uri, name, displayName (a launcher’s own Name=, else name), mime, icon (ready for image://solium/icon/, as Apps’ own is), isDir, isLauncher, trusted, hidden and modified. Unlike Apps, a row that leaves the folder is gone outright, not a ghost (nothing pins a desktop file the way a dock pins an app id), and unlike Apps’ wholesale rescan on a reload only, Folder is live: crate::folder::Watcher holds one inotify watch on the directory, polled every frame, and any change rescans the whole folder, wholesale, exactly the way Apps already prefers a simple rescan over incremental tracking. Empty, and costing nothing to watch or scan, with Solium.dirs.desktop empty. Solium.dirs.desktop is $XDG_DESKTOP_DIR, or the same key read from user-dirs.dirs, empty when neither names one or it names $HOME itself – a user whose whole home is “the desktop” gets no icons drawn over every file in it (crate::folder::desktop_dir).

A scene asks the compositor to do something about one entry the same way it asks anything else, Solium.send(action, data), answered by lua/actions.lua forwarding to sol.act (the folder service alongside windows, workspaces and apps): folder.open { uri } runs it – a regular file through the default application for its MIME type (the freedesktop mimeapps.list/mimeinfo.cache convention, crate::folder::default_app_id), a .desktop launcher through its own Exec= once trusted – launched the same way apps.launch launches, through crate::apps::launch_argv and the same Command::Spawn, so it gets the same clean environment and loading window. folder.trust { uri } marks a launcher trusted, durably, so it will run: tried first, gio set <file> metadata::trusted true itself answers “not supported” on this machine (GIO’s metadata:: namespace needs the gvfs metadata daemon, which nothing here can assume is running), so trust is kept the simplest durable way this repo already has instead – a plain file of trusted absolute paths under $XDG_DATA_HOME/solium/desktop-trust (crate::folder::Trust), the same fs::write shape session.rs and launch.rs already use for small state. sol.store would be the natural fit once it exists. done’s reason here adds "unknown-file" (no such entry), "no-handler" (no default application for the MIME type) and "untrusted" (an un-trusted launcher) to the vocabulary above – not yet listed there, the same gap apps.launch’s own "unknown-app" already left.

What is cut from this version: a subdirectory is one isDir row, not walked (a folder window is a scene’s own concern, not here yet); MIME detection is /usr/share/mime/globs2’s extension table, by hand, with no content sniffing; hidden follows only the leading-dot convention, not a .hidden list; folder.open_with (quick search as an app picker) and a durable, position-aware store once sol.store exists are both Later (crates/solium/src/folder.rs’s own module doc has the rest).

The keyboard, live. Keyboard, written unqualified like Theme, is the keyboard every scene reads, a window’s frame as much as a shell: layout (the live layout’s index into layouts, from 0), layoutName ("Russian"), layoutShort ("RU", the short name xkb’s own rules give it), layouts (every layout’s name, in order, as layoutName spells each), caps and num, each notifying as it changes, and changed(what) once in each frame in which the layout, Caps Lock or Num Lock really changed, what being "layout", "caps" or "num", and never for ordinary typing (models::keyboard::tests::the_keyboard_singleton_changes_once_for_a_layout_switch_and_a_caps_toggle). Before the keymap is known, layoutName and layoutShort read "". It is published once a frame, beside the monitors. It has no row types of its own, so the one name it takes in import Solium is Keyboard itself, as Theme takes Theme. It says what the keyboard is, where Solium.keyboard, above, is how an item asks for it, and a switch made while a scene holds the keyboard is told all the same (keyboard_change::tests::a_switch_made_while_a_scene_holds_the_keyboard_is_told_and_the_scene_types_with_it). Lua hears the same changes through sol.on("keyboard", ...), but for those made at the lock screen (state::tests::real_client::lock_focus::a_caps_toggle_at_the_lock_screen_is_not_told_to_the_configuration), and reads the focused text field’s caret with sol.text_input(), hearing it move through sol.on("text_input", ...), once a pass of the event loop at most (text_input::tests::text_input_is_told_when_the_caret_moves_once_a_pass).

Room of its own. A surface reserves edges of its monitor whatever its size. Lua declares it, reserve = { bottom = 48 } on sol.surface, and the scene can say it too, Solium.surface.reserve.bottom: bar.hidden ? 0 : 48, which wins for each edge it sets (state::tests::real_client::reflow_on_close::hosted::a_scene_reserve_overrides_its_edge_and_reflows_the_layout_once). A negative value, such as -1, gives that edge back to the declaration (qml::hosted::tests::a_negative_scene_reserve_gives_the_edge_back_to_the_declaration). Each edge of the work area loses its layer-shell zone and every reserve on it, added together (state::tests::real_client::reflow_on_close::hosted::two_surfaces_reserving_one_edge_take_both), and sol.monitors() and Solium.monitor.area are what is left (state::tests::real_client::reflow_on_close::hosted::a_declared_reserve_takes_its_edge_out_of_the_work_area). The reserving surface itself still covers its whole monitor (state::tests::real_client::reflow_on_close::hosted::a_reserving_surface_is_drawn_across_the_whole_monitor), so quick settings, a popup or a morph can grow out of a bar over the windows without changing the reserve, and no window moves when it opens (state::tests::real_client::reflow_on_close::hosted::a_panel_growing_out_of_the_bar_moves_no_window, qml::hosted::tests::a_panel_grown_out_of_the_bar_leaves_the_reserve_as_it_was). When the reserve does change, the layout runs once, in the dispatch that changed it, and the windows glide into the new work area with the layout’s own motion from that instant, on the compositor’s clock, which is the clock the bar’s animation runs on too (state::tests::real_client::reflow_on_close::hosted::a_reserve_the_scene_changes_at_a_press_reflows_the_tiled_windows_once_from_that_instant, state::tests::real_client::reflow_on_close::hosted::a_reserve_declared_again_or_taken_away_reflows_the_windows_once_each), and that includes a change a sol.on("surface", ...) handler makes, which re-flows the windows at the click that sent the action (state::tests::real_client::a_reserve_an_action_handler_changes_reflows_the_windows_in_the_clicks_dispatch). So bind it to where the bar is going, never to an animated value. A change the scene makes on its own, from a Timer, is read after the frame it was made in (state::tests::real_client::reflow_on_close::hosted::a_reserve_a_scene_changes_on_its_own_is_read_after_the_frame).

A way back to the configuration. Solium.send("windows.focus", { id: model.id }) sends a named action with data, an object, a value or nothing (qml::hosted::tests::solium_send_queues_every_action_with_its_data_in_order). Lua hears it as sol.on("surface", function(surface, action, data) ... end), with the surface’s name and the data as a table, a value or nil, in a dispatch after the one that sent it, every action in the order sent, and decides (script::tests::a_surface_action_reaches_lua_with_its_data, script::tests::an_action_with_no_data_reaches_lua_as_nil, state::tests::real_client::reflow_on_close::hosted::two_actions_from_one_frame_both_reach_lua_in_order). The shipped lua/actions.lua sends the vocabulary on to sol.act, the compositor’s one entry point for its verbs: windows.focus, windows.close, windows.fullscreen and windows.maximize, each with { id = <window id> } (state::tests::real_client::reflow_on_close::hosted::windows_focus_from_a_scene_focuses_the_window). sol.act’s data crosses as JSON, as a surface’s properties do, nested at most 64 deep and 65 536 values in all, a table counted once for each place it is in: a table nested deeper or holding more, or one that contains itself, is an error in the handler that sent it, not a crash or a stall of the compositor (script::tests::data_that_contains_itself_is_an_error_in_the_handler, json::tests::a_table_of_more_than_65536_values_is_an_error_not_a_stall). sol.act(action, data, done) answers an attempt id, and done(ok, reason) hears once whether it was done, after it was; reason is "unknown-action", "unknown-window", "bad-data", for a windows.focus behind the lock "locked", or, for a windows.fullscreen or windows.maximize of an X11 window, which the compositor cannot send there, "unsupported" (no test can make an X11 window, so that one is read in Solium::act), and a window still loading, one sol.windows() lists before its application has arrived, is "unknown-window" to all but windows.close (script::tests::sol_act_returns_an_attempt_and_done_hears_the_outcome_once, state::tests::real_client::reflow_on_close::hosted::sol_act_answers_why_it_could_not, state::tests::real_client::reflow_on_close::hosted::sol_act_tells_done_once_the_window_was_asked_to_close, state::tests::real_client::reflow_on_close::hosted::sol_act_on_a_window_still_loading_answers_unknown_window_but_closes_it, state::tests::real_client::lock_focus::sol_act_focus_behind_the_lock_is_answered_locked). It is told once the whole dispatch that ran sol.act is applied, a hotplug’s or a reload’s handlers included, and a done that acts again is told again in that dispatch, 16 rounds at most, the rest at the next one (state::tests::real_client::reflow_on_close::hosted::done_is_told_after_every_command_of_the_batch_that_ran_its_act, state::tests::real_client::reflow_on_close::hosted::a_sol_act_in_a_hotplugs_handler_hears_done_in_the_hotplugs_dispatch, state::tests::real_client::reflow_on_close::hosted::a_sol_act_in_a_reloaded_configuration_hears_done_in_the_reloads_dispatch, state::tests::real_client::reflow_on_close::hosted::a_done_that_acts_again_each_time_it_is_told_costs_rounds_not_the_session). Each done has the whole 100 ms handler deadline of its own, and one stopped at it does not stop the next (script::tests::a_done_that_never_returns_is_stopped_and_the_next_done_still_hears_its_outcome). What a done asked for in the run that was stopped is dropped, the attempts it started with it, so a retry that acts again and then never returns is stopped once, not every round; and its stops are counted by function, as a listener’s are, so one stopped three times is not called again until a reload, while a done written inline is a new function at each sol.act, and is stopped each time rather than taken out (script::tests::what_a_stopped_done_asked_for_is_dropped, script::tests::a_done_stopped_three_times_is_not_called_again, script::tests::an_inline_done_is_stopped_each_time_and_never_taken_out, state::tests::real_client::reflow_on_close::hosted::a_done_that_acts_again_and_never_returns_does_not_stall_every_dispatch). A workspaces.* action is the configuration’s to answer, since the compositor does not know what a workspace is: a file answers one with actions.override(name, function(data, surface) ... end), and until one does, sol.act answers it "unknown-action". An action outside the vocabulary goes only to the listeners for its surface, as a tweak’s does to tweaks.lua (script::tests::actions_lua_routes_the_vocabulary_and_leaves_the_rest_alone). An override is a surface listener of its own, under the same 100 ms deadline as every listener, so one stopped three times is taken out alone: every other action is still routed, and its own goes to sol.act again; a later override of the same name replaces it, and one of nil gives it back to sol.act (script::tests::an_override_stopped_three_times_is_taken_out_alone, script::tests::a_later_override_of_the_same_name_replaces_the_earlier_one, script::tests::an_override_of_nil_gives_its_action_back_to_the_compositor). A scene that sets a string property named action, as scenes did before Solium.send, is heard the same way, after what it sent, with no data (surface::tests::queued_actions_come_first_and_the_old_action_property_last). Anything Lua can do — sol.spawn, switching workspaces, any binding — a hosted shell can ask for this way.

What a pointer scene is given

The pointer is a scene too, when the configuration names one: cursor = { scene = "~/.config/solium/cursor/Cursor.qml" }, found as shell.scene is, or SOLIUM_QML_CURSOR for one run. It is built in the same engine as every other scene and is given this (ricing.md has an example):

  • Every named shape. It is drawn for every shape a window names over its surface, through cursor-shape, and for every one the compositor asserts over its own chrome: the arrow over a titlebar, the resize arrows on the edges. No theme is asked (models::pointer::tests::a_configured_scene_hears_every_named_shape). A window that attaches a cursor surface of its own, or hides the pointer, is still drawn as it asked, over its own surface (cursor::tests::a_pointer_a_client_hides_stays_hidden_with_a_scene_configured).
  • Solium.cursor, published once a frame for as long as a pointer scene is configured, and read the same in every scene: shape, the shape by its CSS cursor name (default, text, pointer, ew-resize, and so on); pressed; velocity, a point in logical pixels a second over the last frame, zero once the pointer stops; scale, the scale of the monitor under the pointer; and size, the configured cursor.size (models::pointer::tests::a_named_shape_reaches_solium_cursor_shape, models::pointer::tests::the_published_pointer_is_its_buttons_its_motion_its_monitor_and_its_size). A pointer that moved asks for the frame after, which publishes it standing still, and with no pointer scene nothing is published at all (models::pointer::tests::a_moving_pointer_asks_for_the_frame_that_says_it_stopped, models::pointer::tests::nothing_is_published_with_no_scene).
  • A size of its own. The root’s width and height, up to 256 logical pixels a side, which the compositor reads and never writes, so a binding on them holds; a root that sets neither is cursor.size square (qml::pointer::tests::a_scene_sized_by_its_root_keeps_its_own_size).
  • A hotspot. Solium.cursor.hotspot on its root is the point of its picture that sits on the pointer, the top-left corner by default. The picture is placed so that point is where every hit test asks, at every scale (qml::pointer::tests::the_hotspot_the_root_sets_is_the_scenes, cursor::scene::tests::the_scene_is_drawn_at_its_own_size_with_its_hotspot_on_the_pointer).
  • The compositor’s clock. An animation in it runs on the clock every other scene’s does and asks for the next frame while it runs; at rest it asks for none (cursor::scene::tests::an_animating_scene_asks_for_the_next_frame_only_while_it_animates). ShaderEffect and MultiEffect draw on the GPU path (dev/wirecheck’s case 11); on the software path they draw nothing and the scene’s plain drawing is what shows.
  • The cursor plane. Its picture is a Kind::Cursor element on both paths, so it is offered to the hardware cursor plane, and goes there when its size in device pixels fits the plane (commonly 64 or 128 pixels a side); a larger one is composited like any other element. A picture that reads the backdrop would be composited whatever its size; nothing can until materials exist (cursor::scene::tests::a_scene_with_no_material_is_a_cursor_plane_element).
  • Only the monitors it reaches. It is drawn on each monitor its picture reaches onto, both of two while it crosses the edge between them, and not on the others (cursor::scene::tests::a_picture_is_drawn_only_on_the_outputs_it_touches).
  • A reload that swaps it. super+shift+r builds it again when the file, any QML in its directory or the directories under it, or cursor.size changed, as an edit anywhere in a shell does, or when it would not load last time; one that would not load leaves the theme and the shipped arrow to draw the pointer (cursor::tests::a_reload_swaps_the_scene).

It is not hosted on a monitor and takes no input: Solium.monitor is the absent row, and Solium.send, Grab and Solium.keyboard do nothing in it. With no scene configured, nothing of this runs: the pointer is the theme, and then qml/cursor.qml, as before (cursor::tests::with_no_scene_configured_the_pointer_is_drawn_as_before).

What it is not given

Said plainly, because a shell that loads is easy to mistake for one that works:

  • No compose or dead keys in a hosted field, and no input method: a field types what the key types.
  • No clipboard of the session’s. ctrl+c and ctrl+v in a hosted field copy and paste within the compositor’s own Qt: what a window copied cannot be pasted into it, nor the other way round.
  • No icons. There is no image:// provider for the icon theme. Driving the compositor goes through Solium.send and Lua.
  • No touch. A scene takes no touch: a tap where it takes a press triggers nothing there, and neither reaches nor focuses the window under it; elsewhere a touch reaches the window under the shell, as the pointer would (state::tests::real_client::reflow_on_close::hosted::a_touch_on_a_shell_button_neither_reaches_nor_focuses_the_window_under_it). Scenes getting touch is #181; gestures are the touch epic #7, after v0.1.0.

What is still a client

Ordinary applications, over xdg-shell.

And any shell that is a program of its own, over wlr-layer-shell: Waybar, a wallpaper program, a Quickshell instance started as a process, a lock screen. They are started like any other program and need nothing from the configuration. Their top layer is drawn over a hosted shell, and the compositor reserves their exclusive zones from the work area.

Where a client’s bar goes, with more than one monitor

A layer surface names the output it wants, and the compositor honours it. That is how a client shell puts a bar on every screen: one surface per output, each with its own exclusive zone, each reserving from that monitor’s work area.

A surface that names no output gets the primary monitor — the one primary = true picks out in monitors, or the first if nothing does. Not the monitor the pointer is on, which is what it briefly was: a dock connects once, at startup, and pinning it to whichever screen the mouse happened to be over then means it appears somewhere different depending on where the mouse was left. That looks like the compositor placing it at random, because it is.

sol.monitors() gives a script the list, with primary and focused flags, so a shell written in Lua can decide for itself which screens get a bar.

What hosting costs, honestly

A hosted shell cannot crash independently of the compositor. A separate process can be restarted; a QML error in the dock takes the session with it unless the compositor is careful. So:

  • Every scene is loaded defensively. A scene that fails to load is skipped and logged; it never stops the compositor starting.
  • Scene errors are contained per surface — a broken dock must not take the window frames with it.
  • An edit that does not parse leaves the last scene drawing.

That is the trade being made deliberately: robustness through care inside one process, in exchange for a desktop that can actually do what the design asks for. A shell that would rather have a separate process’s robustness can have it, as a client, and gives up the one engine to get it.

How the rest of the desktop starts

Whatever Solium loads itself starts with it and needs nothing below: its own QML scenes, and everything init.lua declares. A shell that Solium loads through its configuration is one of those, and needs none of this either. Every other program of the desktop is a separate process, and on a systemd machine it is started by the user’s systemd, or by D-Bus when something first asks for it. That covers a polkit authentication agent, a keyring, applets such as nm-applet, a bar that is a program of its own, and the portals.

So Solium tells both where it is (#146), when it is started as the session. The session file starts solium-session, which runs solium --tty --session. Once its Wayland socket is up, Solium sends WAYLAND_DISPLAY, XDG_CURRENT_DESKTOP (Lilium, unless the session file’s DesktopNames set it already) and XDG_SESSION_TYPE=wayland to systemd’s user manager (SetEnvironment) and to D-Bus activation (UpdateActivationEnvironment). It sends them again with DISPLAY once XWayland has one. Then it starts solium-session.target, which starts graphical-session.target, and solium-autostart.target, which starts xdg-desktop-autostart.target. session.rs has the tests, and the_calls_reach_systemd_and_dbus_as_their_methods checks the calls on the wire.

On the way out Solium stops solium-session.target, which takes the autostart target with it, and unsets the variables in systemd, so a Plasma login afterwards does not inherit a dead socket. It does so on a clean exit, and on SIGTERM (how logind ends a session), SIGINT or SIGHUP (signals.rs). A Solium that cannot stop, because something inside it has stopped answering, ends itself session.stop_timeout (five seconds) after the signal, or at once when the same signal comes again half a second or more later. Neither that nor a crash gives Solium the chance to clean up, so solium-session does it once Solium has gone, if the target is still active. If Solium failed before it started the target, while it waited up to five seconds for XWayland, solium-session unsets the variables, as long as no other desktop holds graphical-session.target (dev/install-check.sh checks each case).

Two things are left behind even so. A SIGKILL of the whole session, which systemd sends to whatever of it is still running once its stop timeout has passed (45 seconds on Fedora 44), takes solium-session with it; the next Solium session stops any target an earlier one left running before it says anything. And under dbus-daemon, the D-Bus activation environment keeps the variables, because D-Bus has no call that removes one. Fedora’s dbus-broker hands UpdateActivationEnvironment on to systemd’s SetEnvironment (its launcher names that method), so there the unset should clear them for D-Bus activation too; that has still to be seen on a real session.

One Solium session runs per user at a time: solium-session holds a lock for as long as its session runs, and refuses to start a second, which would take the first one’s target for a leftover and stop it.

When Solium is not the session, it tells nobody. solium --tty started by hand, from a text console, leaves systemd and D-Bus alone: a desktop on another VT shares them, and its display is not Solium’s to replace. A nested Solium leaves the session it runs in alone for the same reason. And a Solium session that finds graphical-session.target already held by another desktop of the same user leaves systemd and D-Bus to that desktop, and says so in the log.

session.systemd = false turns all of it off. session.autostart = false leaves out solium-autostart.target, and so XDG autostart, while solium-session.target and every unit attached to it still start. dev/install.sh puts both targets in ~/.config/systemd/user and lilium-portals.conf in ~/.config/xdg-desktop-portal.

A program that should start with a Solium session can do it in one of two ways.

XDG autostart. Put a .desktop file in ~/.config/autostart, or in /etc/xdg/autostart for every user. systemd’s autostart generator turns each into a service when xdg-desktop-autostart.target starts. It skips any whose OnlyShowIn= does not name Lilium, whose NotShowIn= does, or that says X-systemd-skip=true:

[Desktop Entry]
Type=Application
Name=Network applet
Exec=nm-applet --indicator
OnlyShowIn=Lilium;

A systemd user unit. It is tied to the graphical session, so it stops when Solium does, and attached to solium-session.target, so it starts whether autostart is on or not:

[Unit]
Description=A bar
PartOf=graphical-session.target
After=graphical-session.target

[Service]
ExecStart=/usr/bin/waybar
Restart=on-failure

[Install]
WantedBy=solium-session.target

Save it as ~/.config/systemd/user/bar.service and enable it with systemctl --user enable bar.service. WantedBy=graphical-session.target also starts it under any other desktop that runs a systemd session, such as Plasma or GNOME. solium-session.target starts it only under Solium.

Polkit agents and keyrings start the same way, and most say which desktops they are for. Fedora’s KDE polkit agent, for instance, ships /etc/xdg/autostart/polkit-kde-authentication-agent-1.desktop with OnlyShowIn=KDE; and X-systemd-skip=true, so autostart skips it under Lilium, and a copy of it would be skipped too. It also ships plasma-polkit-agent.service, which is already PartOf=graphical-session.target, so a single command starts it with every Solium session, autostart on or off:

systemctl --user add-wants solium-session.target plasma-polkit-agent.service

A keyring that D-Bus starts on demand, such as KWallet’s kwalletd6, needs no entry: once the environment is exported, the first program that asks for a secret starts it. It is not unlocked with the login password, though. Under Plasma, plasma-kwallet-pam.service runs /usr/libexec/pam_kwallet_init, which hands the wallet what PAM kept at login, through the socket named by PAM_KWALLET5_LOGIN. Solium does not export that variable to systemd, and the script does nothing without it, so the wallet asks for its password the first time it opens.

Summary

Where it runsHow it reaches the compositor
ApplicationsClientsxdg-shell
Window framesCompositor’s QML enginedirectly
Loading windows, the shipped pointerThe same QML enginedirectly
A pointer sceneThe same QML enginecursor.scene, through sol.cursor_theme
The wallpaper, other scripted scenesThe same QML enginesol.surface from Lua
A hosted shell: bar, dock, launcherThe same QML engineshell.scene, through sol.surface
A client shell (Waybar and the like), wallpaper programsClientswlr-layer-shell
A polkit agent, a keyring, applets and other separate programsClients, started by systemd or D-BusXDG autostart, or a unit PartOf=graphical-session.target
Which monitor a bar is onHosted: every monitor, or the one shell.on names. A client: the output it namesthe output argument of zwlr_layer_shell_v1.get_layer_surface
Colours and metricsSolium.Theme, one singletonimported by every scene that wants it
What an animation doesLua scriptsol.present_from, sol.on("open")

History: the dock that proved it

The first in-compositor dock was compositor code — shell.rs and sol.dock — and it was taken back out, as the compiled-in bar before it had been: it made the compositor own a design, a font stack and a layout it had no reason to, and it hid the question of how a replaceable shell attaches instead of answering it. layer.rs records that for the bar, and surface.rs for the dock. The answer is the one above: the shell is configuration, and the compositor hosts whatever the configuration names.

The dock was a QML scene rendered by the same host that draws the window frames, importing the same Solium.Theme, reserving its own strip out of work_area. The morph was the whole argument made concrete: pressing an icon fired a dock event carrying the rectangle that icon occupies, a script spawned the program and handed that rectangle to sol.present_from, and the window grew out of it. Captured frame by frame in docs/morph.png, a record of 2026-09-06 and of a dock that no longer exists — 460x208 near the dock, then 928x504, 1192x672, settled at full size about a third of a second later.

sol.present_from is still there and still does that. What is missing is a dock to give it a rectangle. config.lua carried a dock.morph duration for the day one exists, and #117 removed it — nothing had ever read it, and a setting configuring a component that does not exist cannot be told apart, from outside, from one that is broken. --check reports that spelling as an unrecognised key now, and a dock brings its settings back to that spot when it arrives.

None of that is reachable from a separate process. A client dock can pass a rectangle over IPC, but by the time the window exists the two are separate scenes and nothing holds both at once to interpolate between them. That is why a shell is hosted here rather than run beside the compositor as a program of its own: hosted, its icons are in the same engine as the window frames, so the flight planned above can be a live instance of the icon’s own component rather than a fake.

Pane styles

A pane is one window and its decoration. A style is the folder that says what that looks like: a Pane.qml naming what the frame reserves and a list of layers, each its own QML scene, each at its own depth.

There is nothing to compile and no compositor code to touch: write a folder, name it in your configuration, and it draws every window.

-- ~/.config/solium/user.lua
return { pane = "left" }

A name is one of the folders here, or one of your own in ~/.config/solium/qml/panes/ – yours shadows a shipped one of the same name. A path is anywhere. SOLIUM_PANE=left does the same thing for one run, which is the quicker way to try one.

Press super+shift+r and the running session picks up the change: the configuration is read again, the QML cache is dropped, and every pane is rebuilt. Windows keep their slots, and each client is resized to whatever the new style left it.

Everything the shipped layers draw with – colours, fonts, spacing – comes from Solium.Theme, the same singleton the loading window reads, and a hosted shell that imports Solium can read it too. The shipped theme is dark and has no hue in it: near-black bars, light grey titles, grey buttons, and an accent that is a light grey too. The fallback pointer and the default wallpaper do not read it: their colours are fixed. Nor, in part, does the keyboard pill: its capsule is Theme.accent and its glyph and label Theme.accentInk, but its shadow is fixed black. A Solium/Theme.qml of your own in ~/.config/solium/qml/ is meant to restyle all of them without touching anything that ships, and does not yet, because the shipped module is found first (#88).

The styles with buttons – top, bottom, left, reveal, reactive and pulse – tell close from maximise without a colour: under the pointer each turns its own grey (Theme.danger, the lighter, and Theme.warning) and shows its glyph on it in Theme.accentInk, × to close and + to maximise. crates/solium/tests/scenarios/pane-buttons-drawn.lua points at each and reads the pixels.

The manifest

Pane.qml is the entry point, and its root is a PaneStyle. It declares structure; it never draws. The compositor builds it once at 1x1, reads what it says, and throws it away.

import QtQuick
import Solium

PaneStyle {
    insets.top: 32
    requires: []

    Layer { depth: "behind"; bleed: 24;            Rectangle { anchors.fill: parent } }
    Layer { depth: "frame";  source: "Frame.qml" }
    Layer { depth: "above";  bleed: { "top": 48 }; source: "Spikes.qml" }
}
on PaneStyle
insets.top, .right, .bottom, .leftwhat the style reserves from the client, once, for the whole style
requireswhat the style needs from the machine. ["gpu"] is the only term today: the software scene graph does not implement ShaderEffect, and Canvas does not appear to paint on it, so a style using either says so. A style whose terms this session cannot meet, or that names one this build has never heard of, is refused rather than drawn wrong: its windows are left bare, and the log names the term. A nested session renders in software under the default qml.renderer = "auto"
client.radiusrounds the client’s own surfaces, in logical pixels. The compositor cuts the corners as it draws the client, at no extra cost: no pass and no copy of the window (see “What it costs”). 0 is no effect at all, and so is leaving the key out — which is what nine of the eleven bundles that ship do. The example fixture writes 0, to show the key exists and costs nothing; rounded/ and flush/ are the two that round
client.radiusTopLeft, .radiusTopRight, .radiusBottomLeft, .radiusBottomRightone corner each, in logical pixels. Every one of them defaults to client.radius, so a style that wants four the same writes one key and these never come up. A 0 has to be written out: squaring a corner is half of what these are for, so an absent corner follows radius rather than being square. flush/ is the shipped example
client.shadowreserved for the shadow cast by the client’s silhouette; declared, and read by nobody yet
the Layer childrenthe layers, in declaration order

Insets are on the style and never on a layer. The client is placed once and every layer sees the same client rect, so three layers each declaring insets would be three answers to one question.

A folder without a Pane.qml is not a style. A bare name skips it and goes on to the next place it would have looked, so an empty panes/top/ of your own does not quietly replace the shipped top with a window that has no frame.

In a checkout of the repository, crates/solium/tests/fixtures/panes/example/ is the format written out in full – all three depths, both spellings of bleed, inline content and delegated content – and

solium --check-qml crates/solium/tests/fixtures/panes/example/Pane.qml

says whether it still parses. It loads every layer the style names in source: too, prints ok for each file or what Qt reported, and exits with 1 when any of them does not build, naming the file.

A style that cannot be loaded – a layer that will not build, a requires that cannot be met, a name that is nowhere – leaves each window it was for with no frame at all and no space reserved for one, and says so in the log (#90). Fixing it and pressing super+shift+r frames the windows opened after that; one already left bare stays bare until it is opened again.

Layers

on Layer
depth"behind" the client, in the "frame", or "above" the frame. Both frame and above are drawn over the client, above over frame. A string, so a fourth value added later does not break every style already written. An unknown word draws at frame and says so in the log
bleedhow far past the pane this layer may paint. 24 for every side, or { "top": 48 } for one. Zero by default
sourcea QML file in this folder, when the content is not inline
namefor diagnostics: which layer a warning is about
dormanttrue while the layer has nothing to draw. False unless bound. Read on an inline layer’s Layer; a layer with source: declares property bool dormant on its own file’s root instead, and one written on its Layer is ignored. See “What it costs”

Content is written inline or delegated with source:, and the syntax does not change between the two. Each layer is rasterised into a scene of its own and becomes its own element in the frame, which is what lets the client’s surface sit between two layers of one style – the thing a single QML file can never do.

Layers at one depth are drawn in declaration order, the later one on top. Pointer input goes to every layer. When more than one layer sets action for the same press, the topmost is the one acted on.

A delegated layer’s root is an ordinary Item. It is its own scene, with no parent to read anything off, so it declares the properties it uses itself.

What the compositor sets on every layer

Every frame:

property
titlethe window’s title
focusedwhether it has the keyboard
pointerInsidewhether the pointer is anywhere over the window
contentWidth, contentHeightthe client’s size, inside the insets
paneWidth, paneHeightthe window’s own outer size

When they change, in place, and to a frame built later as well:

property
caretwhere the focused text field’s caret is, when this window has it: { valid, x, y, width, height } in the pane’s own space – the space a layer with no bleed is laid out in – and valid: false when the field goes or the keyboard leaves the window. Known from applications that say where their caret is, through text-input-v3. Exact while the window is at rest: it takes the client to sit just inside the insets, so while a resize places the client otherwise for a moment, it can be off by that much. Declared on PaneStyle; a delegated layer that wants it declares property var caret: ({ valid: false })
valueswhatever the configuration handed every pane with sol.pane_values{ key = value }, as one object. Each call merges its top-level keys into what is there, a key’s value replaced whole, so two scripts that hand keys of their own both reach the layer. A general channel from Lua to the frames: a setting the configuration reads reaches the layer that draws by it, and the compositor never knows what it is. A reload starts it empty, so a key the reloaded configuration no longer hands over is gone. Declared on PaneStyle; a delegated layer declares property var values: ({})

A layer drawing at the caret is drawn with its window, so whatever the window is doing – moving, scaling in a thumbnail, fading – the drawing does too. Every layer can also read the keyboard, Keyboard in import Solium: the live layout, its names, and whether Caps Lock and Num Lock are on (docs/shell-boundary.md, “The keyboard, live”). The attached Solium object and Grab are a hosted shell’s. In a layer they build and do nothing, and Solium.monitor reads an absent row with an empty name (docs/shell-boundary.md, “The Solium QML module”).

Once, at build, because neither ever changes for a style:

property
insetTop, insetRight, insetBottom, insetLeftwhat Pane.qml reserved, so a bar can size itself to its own band without a second copy of the number
bleedLeft, bleedTopwhere the window’s own corner is inside this layer’s canvas
clientRadiusTopLeft, clientRadiusTopRight, clientRadiusBottomLeft, clientRadiusBottomRightwhat the compositor is cutting each corner of the client to, in logical pixels, so a bar or a border can hug that curve. Written on every layer of every style, zeroes included — a corner left unwritten reads back 0, which is indistinguishable from one the style really squared
clientRadiusthe largest of the four above, for a layer that wants one number. Its use is an outward hug (radius: clientRadius + 2), and a hug has to clear the biggest cut; a layer that needs one particular corner reads it by name. Written on every layer of every style, zero included. rounded/Frame.qml is the worked example

Of these, PaneStyle declares only bleedLeft and bleedTop. The four inset names and the five clientRadius names reach only a delegated layer that declares them on its root. An inline layer reads its manifest instead: insets.top and the other three, client.radius, and client.radiusTopLeft and the other corners, where -1 means the corner follows client.radius. It has no clientRadius.

Read back by the compositor:

property
actionset to "close" or "maximize" to ask for it; cleared once taken
onButtontrue while the pointer is over a button. A press on the frame starts a window drag unless some layer says this
dormanttrue while the layer has nothing to draw, so it is not drawn, and in software keeps no image. An inline layer binds it on its Layer and PaneStyle hands it on; a delegated layer declares property bool dormant on its own root

A layer declares only the ones it uses; one that positions nothing against the window declares no bleedTop and is handed a property it ignores.

A layer whose buttons do not declare onButton has them drag the window instead of pressing. The shipped top keeps the name of the button under the pointer in a hovered string of its own and derives the flag from it:

property string hovered: ""
readonly property bool onButton: hovered !== ""

hovered on its own is read by nothing. left, bottom, pulse, reactive and reveal declare hovered and not onButton, so their buttons may not press (#158).

The pointer arrives as ordinary mouse events, in the layer’s own coordinates, while it is anywhere over the window – including over the client, which is how a border can follow the cursor. When it leaves, the layer is told with a position outside itself, so MouseArea.containsMouse goes false on its own. A layer with bleed is given the pointer in its own canvas, so a click lands on whatever that layer drew there. Presses are narrower than hover: they reach the layers only inside the band the insets reserve, so a button drawn over the client, out in the bleed, or in a style that reserves nothing cannot be pressed. Touch reaches no layer: a finger on a frame’s close or maximise button, or on its edge, presses nothing and resizes nothing (#181), though outside the tablet and phone form factors the tap still focuses the window.

What it costs

QML renders on one of two paths: the GPU, the default on the hardware when a trial render at startup passes, or software, which is what a nested session, qml.renderer = "software" and a failed trial get. The log line that begins QML renderer: says which.

On both, anchors.fill: parent fills the canvas, which is the pane grown by the bleed this layer declared – not the window. Bleed is a cost and not a permission: the canvas is that much larger, and every pixel of it is drawn again when the layer changes. A bar throwing spikes upward should ask for { "top": 48 } rather than 48, and not pay for three sides it never touches. It is also a promise rather than a request: the layer is clipped to the canvas it asked for, so no one style can force a full-screen repaint every frame.

On the GPU a layer is drawn straight into a buffer the compositor allocated, and nothing is copied or uploaded.

In software a changed layer is rasterised on the CPU, copied and uploaded. A layer that stays inside the style’s own insets has only those bands copied, and the upload is the one box around them: for a titlebar that is about 4% of a window, and for a style that reserves all four sides it is the whole window. A layer that reserves space and paints outside it must say so, or what it paints outside is not copied:

property bool overlay: true

Declare it only if you need it; the alternative is usually to reserve the couple of pixels you were painting over. A layer at behind or above, a layer with any bleed, and every layer of a style that reserves nothing are overlays already and need not say it. On the GPU the property changes nothing.

A layer that is usually empty can say so. Every layer costs what its canvas costs whether or not anything is in it: a buffer that size, and an element blended over the client wherever the client changes, and in software the whole canvas copied and uploaded at every step of a resize. A layer that binds dormant to “nothing to show” is left out of the frame while it is true, so nothing of it is blended or uploaded, and on the frame it goes to sleep it lets go of its image, so waking is a resize that draws it afresh. On the GPU its scene keeps the buffer it was last drawn into until it is drawn again, and one dormant from its first frame never has one larger than a pixel. It is a general mechanism – any layer may bind it, and nothing in the compositor knows what a layer is for – and the keyboard pill below is the shipped user. Wake it from something it is told (values, caret, focused): a dormant layer’s own animations ask for no frames.

A rounded client costs no pass. client.radius is drawn as the client is: each of its surfaces goes through a fragment program where it already is, so rounded/ and flush/ cut the client’s corners at no extra cost, with no pass and no copy of the window, and keep them while the window is tilted or pulled by a genie. 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.

Animations need nothing declared. Qt is asked each frame whether the scene has anything new to draw, and the compositor draws only then – so an idle layer costs a flag read, a transition runs at the screen’s refresh rate, and a loop with a pause in it survives the pause. Bear in mind only that a layer which never stops animating never stops costing anything. The one measurement, from 2026-09-06 on the software path before the GPU one existed, is the whole compositor at about a tenth of a core with pulse animating at the screen’s full rate. Bind an endless animation to focused, as pulse and the wave demonstration do, and an unfocused window costs nothing.

What is here

folder
top/the default: a titlebar above the window
left/a vertical titlebar down the left side
bottom/a titlebar underneath
border/no bar at all, just a frame around the window
reactive/a border that lights up where the cursor is, with a bar
proximity/a border that answers the pointer entering and leaving
reveal/a bar that slides out of the window’s edge on approach
pulse/a bar with an animation running in it

none is not a folder: pane = "none" draws no frame and builds no scene per window.

Each of those eight is one frame layer, which is what every decoration was before styles had layers, and the keyboard pill’s layer, described below. The rest are here to show what layers add:

folder
rounded/client.radius: the compositor cuts all four of the client’s corners with a fragment program, and the bar – at behind, reaching clientRadius past its band – shows through the two it cut inside the window
flush/the same seam the other way up: radiusTopLeft and radiusTopRight at 0, so the client’s top is square, the bar’s own rounded top is the window’s top, and the two meet flat. The only shipped bundle whose four corners differ
shadow/behind plus bleed: stacked rectangles standing in for a blur

And four demonstrations, which are not shipped and are never offered by name. They live with the test fixtures in a checkout, in crates/solium/tests/fixtures/panes/, and SOLIUM_PANE=<that path>/<name> puts one on every window:

folder
example/the format written out in full, and the fixture the tests build
sandwich/one layer behind the client and one above it, in colours that cannot be confused
wave/sine waves flowing round the whole window, outside it, all bleed at behind and nothing reserved
bleedy/what bleed does to hit-testing, and to the window next door

The keyboard pill

Every style here ends with one line:

PaneStyle {
    Layer { depth: "frame"; source: "Frame.qml" }
    KeyboardPillLayer {}
}

KeyboardPillLayer, in import Solium, is an inline layer at above that draws KeyboardPill – the small capsule saying Caps Lock is on, or which layout you just switched to – just below the pane’s caret, or above it when the pane has no room below. It shows what the configuration hands it, values.keyboard_indicator, and nothing when that says not to: the policy is lua/keyboard_indicator.lua, set by keyboard.indicator in config.lua, and docs/ricing.md has the whole of it. A style of your own gets the pill by adding the same line, and a style without it has none. A window drawn bare – fullscreen, one drawing its own decorations, or every window under pane = "none" – has no style around it at all, and the configuration draws its pill on a surface instead.

KeyboardPill shows what its cue says and decides nothing. cue is { what, serial, hold, duration, after }. what is "caps" (an outlined ⇪), "layout" (Keyboard.layoutShort), "num" (⇭), or "" to hide at once. A cue is taken only when its serial is new, so handing the same cue again shows nothing. With hold: true it stays until the next cue. Otherwise it hides after duration milliseconds (1200 when not given), or, when after names one of the three, shows that one instead, held. While accepts is false, a new cue hides it rather than showing. It is the capsule, 28 high and at least 32 wide, in Theme.accent with its glyph or label in Theme.accentInk, with a margin of 14 on every side for its shadow, and it centres the capsule in itself. KeyboardPillLayer hands it values.keyboard_indicator, which the configuration sets as { show, cue }, and draws only while show is true and the pane’s caret is valid.

It is a layer of its own because it draws over the client, and in software a frame layer that reserves a band copies only that band. So it costs one more scene per window. While no pill is on show it is dormant, which is nearly always: not drawn, nothing blended over the client, and in software no image kept. It is drawn only from the cue that shows a pill until that pill has faded. A style that leaves the line out pays nothing for it at all. crates/solium/tests/scenarios/keyboard-pane-drawn.lua draws it in top, reads its pixels, and reads when it is dormant.

One QML file is still a decoration

A style can also be a single .qml file with an Item at its root, under ~/.config/solium/qml/decorations/, named the same way. It is one layer at frame, and it declares its own insetTop/insetRight/insetBottom/ insetLeft rather than being told them – a single file has no manifest, so it is the only place those numbers can live. It has no manifest to add KeyboardPillLayer {} to either, so with keyboard.indicator.show = "pane" its windows show no keyboard pill: draw one at caret yourself (with property bool overlay: true, since it paints over the client), or set show = "surface".

Nothing ships as one any more: the eight above were single files until the pane-styles work moved them into folders. The path stays because those files exist on people’s machines, and because a style needing neither layers nor bleed should not have to become a folder to say so.

Configuration

This page is config.lua, the file that sets every default, with its comments as the text and its settings as the code. To change a setting, write only that setting in ~/.config/solium/user.lua; Every setting is the index.

Every setting the configuration can reach, in one place.

Values were scattered across the layout scripts as local constants, which is hardcoding written in a scripting language. A setting nobody can find is not a setting. Nothing here needs the compositor rebuilt.

Don’t edit this file: an install replaces it, and it is also the defaults. Write only what you want changed in ~/.config/solium/user.lua ($XDG_CONFIG_HOME/solium/user.lua when that is set) and it is merged over these:

return {
    gap = 4,
    pane = "reactive",
    tiling = { split = 0.618 },
}

Nested tables merge key by key, so tiling = { split = ... } keeps the animations below it. Lists are replaced whole, because a list of widths with one entry changed is a different list, not a longer one.

Not here yet, because nothing reads them from this file (#159):

  • Whether focus follows the pointer, natural scrolling, and the key held to drag a window: SOLIUM_FORM_FACTOR picks all three, and SOLIUM_DRAG_MODIFIER the key alone.
  • The terminal super+return opens: SOLIUM_TERMINAL, or bind super+return yourself in bindings below.
  • The overview’s animations, fixed in lua/overview.lua at 260 ms in and 200 ms out.
  • How long a closing window takes to fade, fixed in the compositor at 190 ms.

gap

Space between windows and around the work area, in logical pixels.

The default:

gap = 12,

wallpaper

The wallpaper.

The one that ships is the default; a path replaces it, and false turns it off. Turn it off if you run swaybg, hyprpaper or a shell that draws its own – a layer surface on the background layer is drawn over this one, so leaving both on means paying for a picture nobody sees.

wallpaper = "~/Pictures/whatever.png",
wallpaper = false,

A list gives each workspace its own background, and it travels with that workspace: workspaces.lua puts it in the same selection as the desk’s windows, so one animation carries both and the wallpaper stops being left behind when you switch.

wallpaper = { "~/Pictures/one.png", "~/Pictures/two.png" },

Fewer pictures than workspaces cycles. It costs a scene per desk on every monitor, built when it is declared, and one screen-sized rasterisation per desk you have actually visited, per monitor, which is why a single image stays a single static surface: every desk sharing one picture makes a wallpaper that slides pixel-identical to one that does not, so it would be memory spent on nothing to look at.

~ is expanded. The image is cropped to fill the screen rather than fitted, so nothing is letterboxed.

What actually draws it is lua/wallpaper.lua and qml/wallpaper.qml, and that is the more interesting part: there is no wallpaper code in the compositor at all. It is a short script around one sol.surface call, which draws any QML scene at any layer on any monitor – so a bar, a dock or a heads-up display is the same call with a different layer. Copy qml/wallpaper.qml to ~/.config/solium/qml/ and the background can be a gradient, a shader or a clock. super+shift+r reloads it. A copy that still shows source needs the picture named by its path here: the shipped one is found beside the shipped QML, not beside yours.

The default:

wallpaper = "solium",

shell

A shell: the bar, the dock, the launcher.

A shell is a project of its own, with its own repository, and it runs here as your configuration: its QML is hosted in the compositor’s own engine, beside the window frames. Name its root file:

shell = { scene = "~/.config/solium/shell/shell.qml" },

false, the default, hosts none. ~ is expanded. It is drawn once on every monitor, over the windows, across the whole monitor, and each instance reads its own monitor as Solium.monitor. on = "primary", or a connector name, draws one instead. It takes the pointer only where its items take input, and the windows under it get the rest (a_press_where_the_shell_draws_nothing_reaches_the_window_under_it). See the_shell_scene_is_read_from_the_configuration and the_shell_is_on_every_monitor_unless_the_configuration_names_one. SOLIUM_SHELL_SCENE overrides this, because that is set per run. super+shift+r picks up a change. See docs/shell-boundary.md for installing one and for what the compositor provides to a shell it hosts.

Its bar keeps the windows out of its strip by saying so in the scene, Solium.surface.reserve.bottom: 48, and quick settings growing out of it move no window. See a_panel_growing_out_of_the_bar_moves_no_window.

outside_click: what a press outside an open popup of the shell does, after closing it: “swallow” (macOS and iOS do this), or “pass” to also click what is under it. A table names popups by their grab’s name:

outside_click = { default = "swallow", ["tray-menu"] = "pass" },

See the_shell_takes_its_outside_click_from_the_configuration and a_press_outside_a_grab_dismisses_it_and_is_swallowed_by_default.

What it asks for, Solium.send("windows.focus", { id: ... }) in its QML, reaches sol.on("surface", ...), and lua/actions.lua has the compositor do the window actions. See windows_focus_from_a_scene_focuses_the_window.

keyboard.bindings: while the shell holds the keyboard (a search field, a password), which compositor bindings still work: “except_claimed” (the default; every binding but the keys the field claims), “all”, or “none” (every key but the Ctrl+Alt escapes goes to the shell). See the_shell_takes_its_keyboard_bindings_from_the_configuration and a_claimed_key_reaches_the_scene_and_not_its_binding.

A layer-shell bar (Waybar and the like) needs nothing here: it is an ordinary client, and you start it as one.

The default:

shell = {
    scene = false,
    on = "every-monitor",
    outside_click = "swallow",
    keyboard = { bindings = "except_claimed" },
},

preview

The compositor’s own preview shell: a bar, built as configuration on the same public API shell above describes (lua/preview/, qml/preview/; see docs/ricing.md). On by default, so a fresh install shows live workspaces, windows, the keyboard layout and a clock rather than a blank desktop.

preview = false,

Turns it off. Naming your own shell.scene above, or user.lua’s own shell, always wins over it either way – this only ever fills in a default nothing else set.

A table instead of true reaches the top dock (lua/preview/dock.lua, 04-ui.md §4.6): preview = { dock = { pinned = { "org.mozilla.firefox", "kitty" }, visibility = "always", icon_size = 48 } }. pinned is a list of desktop ids; left unset, the dock picks the first installed terminal, file manager and browser itself. visibility is "always" or "autohide" (the default).

Quick search (lua/preview/search.lua, 04-ui.md §4.7) opens on super+d: preview = { search = { key = "super+alt+space" } } binds a different one instead. It searches installed applications, open windows and a few compositor commands; see docs/ricing.md for what is cut from this first version.

Desktop icons (lua/preview/desktop.lua, 04-ui.md §4.9) show Solium.dirs.desktop ($XDG_DESKTOP_DIR, or user-dirs.dirs; empty draws nothing): preview = { desktop = { icons = true, from = "top-right", cell = { width = 96, height = 100 }, labels = 2, open = "double", show_hidden = false, reveal_on_click = false } }. icons = false turns them off; from is "top-right" (the default) or "top-left"; open is "double" (the default) or "single". See docs/ricing.md for what is cut from this first version (dragging and saved positions, right-click menus, showing the desktop, Open With, thumbnails).

The default:

preview = true,

keyboard

The keyboard: its xkb layouts (layout, variant, options, model, rules, active), the locks (caps, num), the repeat rate, and the keyboard pill.

No xkb name is set here, and that means “whatever the session already said”. Every name below is an xkb name, and leaving one out makes xkbcommon fall back to the matching XKB_DEFAULT_* environment variable – which is where a display manager or a ~/.profile usually puts it, and which worked before any of this existed. Naming one here takes precedence.

keyboard = {
    layout  = "us,ua",
    variant = ",",
    options = "grp:alt_shift_toggle,compose:ralt",
    model   = "pc105",
},
  • layout: a comma-separated list. The first is the one you start in; the log line keyboard says what the session ended up with.
  • variant: one per layout, in the same order, and blank for “plain”. “us,ua” with “,dvorak” is US ordinary and Ukrainian Dvorak.
  • options: xkb options, comma separated. grp: ones switch layout and are handled inside the keymap, so they work without the compositor being involved. compose:ralt gives a compose key, which is the nearest thing to an input method until #26 lands.
  • model: rarely worth setting; “pc105” is assumed by the rules.
  • rules: the xkb rules, rarer still. Blank, like the others, falls back to the environment.
  • active: which of the layouts is live, counting from 1. A number past the last layout is warned about and changes nothing.
  • caps: Caps Lock on or off, and num the same for Num Lock: num = true here turns Num Lock on when the session starts and again at every reload. Set by pressing the keymap’s own key inside the compositor, so the layout stays where it was.

The repeat rate is the compositor’s own, not xkb’s, so no environment variable reaches it and this is the only place it can be set:

  • repeat_rate: repeats per second after the delay. 25 is the default.
  • repeat_delay: milliseconds held before repeating starts. 200.

super+shift+k cycles the layouts, and it is one call of the same function, so a binding of your own can switch them the same way:

sol.bind("super+shift+k", function()
    local kb = sol.keyboard()
    sol.keyboard{ active = kb.active % #kb.layouts + 1 }
end)

sol.keyboard() with no argument returns the layout names, which one is active and its names (layout_name, “Russian”, and layout_short, “RU”), whether Caps Lock and Num Lock are on, and the repeat settings. sol.on("keyboard", function(state, changed) end) hears the layout or a lock change, however it changed, and a hosted scene or a pane style reads the same in QML as the Keyboard singleton.

keyboard.indicator

The keyboard pill: a small capsule near where you are typing that says Caps Lock is on (⇪) or which layout you just switched to (EN, RU), after the one macOS shows. It is configuration and not a compositor feature – lua/keyboard_indicator.lua is its policy and reads this table; the QML draws it from the data the compositor publishes, in Theme’s accent – so it is the example to copy for anything of your own that reacts to the keyboard or the caret. Delete require("keyboard_indicator") from init.lua and it is gone. See docs/ricing.md, “The keyboard pill”.

The default:

indicator = {
    -- Where it is drawn:
    --   "pane"     inside the window, just below the text field's
    --              caret, or above it when there is no room. Each
    --              shipped pane style draws it with one line,
    --              `KeyboardPillLayer {}`, and moves, scales and fades
    --              it with the window. A style of your own without
    --              that line draws none. A window drawn with no frame
    --              -- fullscreen, one drawing its own decorations, or
    --              any window under `pane = "none"` -- gets the
    --              "surface" pill at its caret instead.
    --   "surface"  on a surface of its own over everything, at the
    --              caret on screen, moved after it as the
    --              application says its caret has moved.
    --   false      nowhere; `indicator = false` says the same.
    -- The caret is known only from applications that say where it
    -- is, through the `text-input-v3` protocol; for the rest, see
    -- `fallback`.
    show = "pane",
    -- Which changes show it: switching layout, Caps Lock, Num Lock.
    -- A lock's pill stays while the lock is on and goes the moment it
    -- is off; a layout's goes after `duration`, and hands back to the
    -- lock's if one is on.
    on = { layout = true, caps = true, num = false },
    -- Show Caps Lock's pill again when a text field is focused while
    -- Caps Lock is on, as macOS does. Only while `on.caps` is true.
    caps_on_focus = true,
    -- With no caret to go to -- an application that says nothing about
    -- one, or no text field focused -- "surface" draws it on the
    -- focused window's monitor, and false draws nothing.
    fallback = "surface",
    -- Where on the monitor that is: "bottom", "center" or "top",
    -- centred across it.
    position = "bottom",
    -- How long a layout's pill stays, in milliseconds.
    duration = 1200,
},

bindings

The keys, and what they do.

The shipped bindings are sol.bind calls in init.lua, and init.lua is the session’s entry point – so until #117 the only way to add one key was to write your own copy of that file and adopt two hundred lines of layout wiring, mode setup and terminal detection along with it, or to edit a file the next update overwrites. Neither of those is a setting. This table is: it is merged over these defaults exactly as tiling and scrolling are, so a user.lua holding nothing but a binding is a complete configuration.

bindings = {
    ["super+b"]       = "firefox",
    ["super+shift+s"] = { "sh", "-c", "grim -g \"$(slurp)\"" },
    ["super+n"]       = function() sol.spawn("kitty", "-e", "nvim") end,
    ["super+g"]       = false,
},

Four forms, each the shortest way to write what it means:

  • a string: a command line, split on spaces. The common case is a program and no arguments, and that should read like one.
  • a list: the same, already split, for an argument with a space in it – the shell quoting above is one argument, not three.
  • a function: anything else. The whole sol API is in scope, so this is a binding in exactly the sense init.lua’s are.
  • false: unbind. A shipped binding you do not want is otherwise unremovable, and binding it to a function that does nothing is not the same thing: the compositor would still swallow the key.

The combination is written the way you would say it and normalised by the compositor, so super+shift+q and Shift+Super+Q are one binding.

A binding here replaces a shipped one on the same combination, rather than being an error. Two reasons, the second deciding. The first is consistency: sol.bind has always let the later call win, so a clash refusing here would make this table alone behave unlike the primitive underneath it. The second is that refusing would permit adding a binding and forbid changing one, and changing one is what people come here to do: super+q closing a window is a choice, not a law, and “you may not rebind it” leaves editing init.lua as the only way – which is the fault this section exists to remove.

Replacing is not the same as replacing quietly. solium --check prints every binding this table produced, marks the ones that took a shipped combination over, and lists the ones it removed, so a clash you did not intend costs a line of output rather than an afternoon wondering why a key stopped working.

Applied by lua/bindings.lua, from the last line of init.lua, because that ordering is the replacement – “the later call wins” only puts these last if they are read last. In your own init.lua, keep require("bindings") last for the same reason.

One thing this cannot check: a combination is whatever you press, so there is no list of valid names to compare yours against. super+whoops binds successfully and never fires. --check prints what was bound, which is the only honest answer available – read it and look for the key you meant. The documentation’s Key bindings page lists every binding that ships, and what each does.

super+return opens SOLIUM_TERMINAL, or else the first terminal that is installed. There is no setting for it yet (#159), so to choose one here, bind the key: ["super+return"] = "foot".

The keys that get around a desktop by direction (#150) are replaced here like any other. They ship in direction.lua and modes.lua:

super+arrows, super+h/j/k/l          focus the window that way
super+shift+arrows,                  move it that way, and at a
  super+shift+h/j/l, super+alt+k     screen's edge onto the next one
super+f                              fullscreen, and back
super+shift+m                        maximised, and back
super+shift+space                    float over the layout, and back

Up on k is super+alt+k because super+shift+k cycles the keyboard layout (init.lua). What a direction does is the layout’s: see tiling.move below, and docs/modes.md. Pressed with either half of us,ru live: the_direction_and_window_keys_fire_while_russian_is_active.

bindings = {
    ["super+ctrl+h"] = function() sol.focus_direction("left") end,
},

The default:

bindings = {},

cursor

The pointer.

Empty means “whatever the session already said”, exactly as keyboard above does and for the same reason: XCURSOR_THEME and XCURSOR_SIZE are what GTK, Qt and every other toolkit on this machine follow. A display manager or a ~/.profile usually sets them, and a compositor that quietly overrode them would be the one thing on screen drawing a different pointer from everything else.

cursor = { theme = "Adwaita", size = 24 },
cursor = { scene = "~/.config/solium/cursor/Cursor.qml", size = 32 },
  • scene: a QML scene to draw the pointer with, configured the way shell.scene is: a path (~ is expanded), or a name looked for in ~/.config/solium/qml/ and then the shipped QML. It is drawn for every shape, ahead of any theme, and reads Solium.cursor: the shape asked for (default, text, pointer, ew-resize…), pressed, velocity, scale and size. It sets Solium.cursor.hotspot on its root, and is as big as its root says, so a glow can reach past size. It may animate. A window that draws its own cursor, or hides it as a game does, still does over its own surface. SOLIUM_QML_CURSOR=<file> is the scene for one run, over this. docs/ricing.md has an example.
  • theme: the name of an XCursor theme – a directory under ~/.icons, ~/.local/share/icons or /usr/share/icons. ls /usr/share/icons lists the ones this machine has. Naming one here takes precedence over XCURSOR_THEME.
  • size: how big the pointer is, in logical pixels, between 8 and 256. Multiplied by each monitor’s scale, so 24 is 24 pixels on a 1x screen and 48 device pixels on a 2x one – which is why a single number is still right on a desk with monitors at different scales. Takes precedence over XCURSOR_SIZE. Outside that range it is ignored, with a line in the log, and the next source has its turn – not clamped, because a pointer three pixels across is as hard to find as no pointer at all and nobody meant to ask for one.

So the order is: what is written here, then the environment, then 24 logical pixels and no theme at all.

No theme is not a missing pointer. Solium draws its own from qml/cursor.qml, and that is what you get with no scene and no theme set here, nothing in the environment, or a theme named that turns out not to be installed – the log says which. It is a white arrow with a dark outline, in fixed colours rather than the theme’s, because a pointer has to read over whatever a client drew, and it is one arrow for every shape. A scene that would not load is drawn as no scene is, with a line in the log, and a reload tries it again.

A shape your theme does not have is the one case that does not reach it. Applications name the cursor they want – an I-beam over text, a resize arrow on an edge – and a theme is free to have drawn only some of them; the missing ones fall back to that theme’s own arrow, so a themed session stays wholly themed rather than mixing two designs.

Applied on reload, so trying a theme or a scene out is super+shift+r; a scene whose files changed is built again then. The compositor call is sol.cursor_theme(...); sol.cursor() is a different function that answers with where the pointer is.

The default:

cursor = {},

qml

How QML – the frames, the pointer, the wallpaper – is rendered.

  • renderer: “auto” renders on the GPU when a trial render in a separate process passes, and in software when it fails or times out, or when this build’s last GPU start in the compositor did not work (see dev/README.md); nested, it is software. “gpu” skips the trial and “software” never uses the GPU. Anything else warns and means “auto”. --qml <mode>, then SOLIUM_QML, then SOLIUM_QML_GPU override this.
  • probe_timeout: how long the trial may take, in milliseconds, before it is killed and QML renders in software.

Read when the compositor starts; a reload does not change it, because Qt cannot change scene graph once started. The last log line that begins “QML renderer:” says which one this session got, and why. Tests: qml/renderer.rs and the_shipped_configuration_asks_for_auto.

The default:

qml = {
    renderer = "auto",
    probe_timeout = 5000,
},

monitors

The monitors.

Empty means “work it out”: every connected screen is driven, left to right in the order the kernel enumerated the connectors, top edges aligned. That is right about half the time, and wrong in a way you can see and fix in one line.

monitors = {
    { name = "DP-1", mode = "2560x1440@260", vrr = true, primary = true },
    { name = "DP-2", mode = "2560x1440@75", right_of = "DP-1", align = "end" },
    { name = "DP-3", above = "DP-1", transform = "90" },
    { name = "HDMI-A-1", enabled = false },
},

name is the connector name; solium --probe prints the ones this machine has, and a name nothing answers to gets a line in the log rather than being ignored. Everything else is optional:

right_of, left_of, above, below   beside another monitor, by name.
                                  Prefer this to x and y: it does not
                                  go stale when a resolution changes,
                                  and a chain resolves whatever order
                                  you write the list in.

align = "start" | "centre"        which way the *other* axis lines up
      | "end"                     when placed beside something taller
                                  or wider. "centre" is the default.
                                  A 1080p beside a 1440p leaves 360
                                  rows belonging to no screen, and
                                  this decides which end they are at
                                  -- which is where the pointer will
                                  catch on the way past.

x, y                              the top-left corner outright, in the
                                  one global space every monitor is a
                                  window onto. `y` is how much lower
                                  one screen sits than another, which
                                  is what a monitor on a taller desk
                                  actually needs.

mode = "2560x1440@260"            resolution and refresh rate. The
                                  refresh is optional -- "2560x1440"
                                  alone means the fastest mode at that
                                  size. A table `{ w = , h = ,
                                  refresh = }` does the same thing, for
                                  generating a configuration rather
                                  than writing one.

                                  Three words also work:
                                    "best"       the highest refresh at
                                                 the preferred
                                                 resolution. The
                                                 default.
                                    "preferred"  exactly what the
                                                 monitor's EDID says,
                                                 refresh included --
                                                 for one that is
                                                 unstable at its
                                                 fastest.
                                    "widest"     the largest
                                                 resolution, fastest at
                                                 that size.

                                  "best" is not "preferred": the
                                  EDID's preferred *flag* names a
                                  resolution and usually pairs it with
                                  a pedestrian 60 Hz. A 260 Hz panel
                                  reports 2560x1440@60 as preferred,
                                  and taking that literally drives a
                                  fast display slowly and makes every
                                  animation look worse than it is.

                                  A mode the monitor does not have
                                  warns and falls back; `--probe` says
                                  how many each one offers.

vrr = true                        variable refresh rate, where the
                                  monitor and the driver both offer it
                                  -- FreeSync, G-Sync compatible,
                                  Adaptive-Sync. The display's refresh
                                  follows what is actually being drawn
                                  instead of the other way round, which
                                  is what removes the tear and the
                                  stutter on anything that cannot hold
                                  a steady frame rate. Left alone by
                                  default, because it interacts badly
                                  with some panels at low frame rates
                                  (visible flicker) and that is not a
                                  thing to turn on for somebody.

transform = "90"                  rotation, anticlockwise, as degrees:
                                  "normal", "90", "180", "270", or the
                                  same with a "flipped-" prefix. A
                                  rotated monitor's work area is
                                  portrait, so every layout follows it
                                  without knowing about it.

enabled = false                   do not drive it. It also frees its
                                  CRTC for another screen, which
                                  matters on a card with more
                                  connectors than CRTCs.

primary = true                    the monitor a layer-shell surface
                                  that names no output goes on, and
                                  the one a hosted shell
                                  (`shell.on = "primary"`) or a
                                  `sol.surface` (`on = "primary"`) is
                                  drawn on when told to. Without this
                                  it is the first monitor -- stable,
                                  but not a choice anybody made.

scale = 2                         how many device pixels to a logical
                                  one. Everything doubles in size: a
                                  window, a titlebar, the pointer, and
                                  the compositor's own QML is
                                  rasterised at that many pixels
                                  rather than stretched.

                                  Left out, it is worked out from the
                                  panel's own size -- 2x above 192 dpi,
                                  which is the number GNOME and KDE
                                  both use, and 1x below. That puts a
                                  13" 4K laptop at 2x and a 27" 4K at
                                  1x, and the second of those is
                                  genuinely a matter of taste, which
                                  is why this is settable. `--probe`
                                  and the log both print the dpi it
                                  measured.

                                  Fractional values work; between 0.5
                                  and 8. A fraction outside that is
                                  warned about and the scale worked
                                  out instead, as far likelier a typo
                                  than a request; a whole number
                                  outside it, such as 0 or 10, is
                                  worked out instead with no warning.

super+shift+r applies a change to the placement keys, scale, primary and enabled without ending the session. mode, vrr and transform are read when a monitor is first lit, so on one already lit they wait until it is next plugged in, or a reload with enabled = false and another with it back.

A monitor plugged in while the session runs is driven with its entry here, and one unplugged leaves its windows on the monitors that remain.

A monitor can also be turned off without being taken out: it keeps its place, its work area and its windows, and only the display goes dark. Three things do it – idle below, sol.monitor_power from a binding, and any client speaking wlr-output-power-management, which is wlopm and swayidle driving it. Any key, click, scroll, touch or motion turns every screen back on, and is delivered as usual: the first key typed at a lock screen that went dark is part of the password.

sol.bind("super+F12", function() sol.monitor_power("all", "off") end)

sol.monitors() says which are off, as power = "on" or "off".

The default:

monitors = {},

idle

When nobody is at the machine.

Solium tells idle daemons how long it has been left alone (ext-idle-notify) and lets a video player, a call or a game say “not now” (idle-inhibit). This is the part it does itself, so that no daemon is needed for the screens to go dark at night.

idle.screens_off_after

Seconds without a key, a click, a touch or the pointer moving before every screen is turned off. 0 never does.

Ten minutes: GNOME blanks at five, but dims the screen first and Solium does not, so here the first sign is the screen going dark. Anything that plays holds an idle inhibitor, and while one is on a screen this waits – a film does not go dark – and starts again from when it lets go, so the next film in a playlist does not start in the dark. Nothing holds it off behind the lock screen, which goes dark like anything else.

Running swayidle instead? Set this to 0 and give it the screens:

swayidle -w timeout 300 'swaylock -f' \
            timeout 600 'wlopm --off \*' resume 'wlopm --on \*' \
            before-sleep 'swaylock -f'

The default:

screens_off_after = 600,

idle.off_frame_interval

How often, in milliseconds, a window on a screen that is off is still told it may draw. Throttled rather than stopped: a program that waits for that inside its swap would otherwise stop dead for as long as the screen is dark. 0 stops it, which is what sway does.

The default:

off_frame_interval = 1000,

idle.dbus_inhibit

Answer the other way programs ask for the screens to stay on: org.freedesktop.ScreenSaver on the session bus, which is what Chrome and Firefox call during a film instead of the Wayland idle inhibitor. Such an inhibitor holds the blank above off – and swayidle’s timeouts – like one on a window in view, goes when the program that took it closes or crashes, and holds nothing behind the lock screen. false leaves the name to whoever else wants it. Nested, it is owned only on the bus SOLIUM_SESSION_BUS names.

The default:

dbus_inhibit = true,

session

Telling the rest of the session that Solium is its desktop.

Portals, programs D-Bus starts on demand, ~/.config/autostart and user services written for “a graphical session” are all started by systemd or by D-Bus, and they find the display only if they are told where it is. Read when the session starts, so a reload does not change it. systemd and autostart count only when Solium is started as the session: the session file’s solium-session, which runs solium --tty --session. solium --tty by hand and a nested run leave the session around them alone, unless SOLIUM_SESSION_BUS names a bus to tell instead. stop_timeout counts however Solium was started. The tests are in session.rs: a_reload_leaves_the_session_as_it_began, a_manual_tty_start_tells_nobody, a_nested_run_tells_nobody_unless_it_is_given_a_bus, the_target_is_stopped_and_the_variables_unset_on_exit, session_systemd_false_tells_nobody_anything and session_autostart_false_starts_the_session_without_autostart.

session.systemd

Tell systemd’s user manager and D-Bus activation WAYLAND_DISPLAY, DISPLAY, XDG_CURRENT_DESKTOP and XDG_SESSION_TYPE, and start solium-session.target, which starts graphical-session.target and every unit you have attached to it. On exit the target is stopped and the variables unset. false does none of it.

The default:

systemd = true,

session.autostart

Whether solium-autostart.target starts beside it: XDG autostart, the programs in ~/.config/autostart and /etc/xdg/autostart, which is how a polkit agent, a keyring or nm-applet usually start. false leaves autostart out, and solium-session.target, with whatever is attached to it, still starts.

The default:

autostart = true,

session.stop_timeout

How long, in milliseconds, SIGTERM, SIGINT or SIGHUP waits for Solium to stop cleanly, however it was started. The first of them stops it the way a quit binding does. If it is still running this long afterwards, something inside it has stopped answering, and it ends at once. The same signal again, half a second or more after the first, ends it at once too. Tests in signals.rs: a_process_that_cannot_stop_ends_when_its_stop_timeout_has_passed, the_same_signal_twice_ends_a_process_that_cannot_stop and the_same_signal_twice_at_once_is_one_request.

The default:

stop_timeout = 5000,

pane

How every window is framed.

A style is a folder under qml/panes holding a Pane.qml: what the frame reserves from the client, and a list of layers, each its own QML scene at its own depth – behind the client, in the frame, or above it. A name is one of the folders below, or one of your own in ~/.config/solium/qml/panes, which shadows a shipped one of the same name. A path is anywhere.

  • "top": a titlebar above the window (the default)
  • "left": a titlebar down the left side
  • "bottom": a titlebar underneath
  • "border": no bar, just a frame
  • "reactive": a border lit where the cursor is, with a bar
  • "proximity": a border that answers the pointer arriving and leaving
  • "reveal": a bar that slides out of the window’s edge on approach
  • "pulse": a bar with an animation running in it
  • "rounded": the window’s own corners cut round, with a bar that fills the two notches at the top
  • "flush": a window with a square top and a rounded bar sitting flush on it
  • "shadow": a drop shadow behind the window, from stacked rectangles rather than a blur
  • "none": no frame at all: no bar, no border, and no QML scene built per window. For a desktop with no window furniture, or a tiling layout whose own bar makes a titlebar redundant.

“rounded” and “flush” cut the client’s corners as it is drawn, at no extra cost: no pass and no copy of the window. The panes README, qml/panes/README.md, has what each style is made of and costs. SOLIUM_PANE overrides this, for one run.

A single QML file still works and is still called a decoration: drop one in ~/.config/solium/qml/decorations and name it here. It is one layer in the frame, which is what every style was before folders.

This setting was called decoration when a style was one file. The old name is still read – see the bottom of this file.

The default:

pane = "top",

loading

What a window does between being asked for and its application arriving. A window’s life starts when you ask for it, not when the program gets around to connecting – these decide what that looks like.

loading.scene

Which QML draws it. A name is one of the scenes in qml/loading, or one of your own in ~/.config/solium/qml/loading, which shadows a shipped one of the same name. A path is anywhere. SOLIUM_LOADING overrides this, because that is set per run.

The default:

scene = "window",

loading.patience

How long to keep a window open for an application that never arrives, in milliseconds. After that the layout is told the window has gone, and the window fades out of its place with the same fade a window you close plays, the scene it was showing fading with it. There is no application to ask, so there is nothing to wait for and nothing to refuse. A window whose scene would not load has nothing on screen to fade, and goes at once.

An application that was already running does not arrive either: it answers the launch by bringing forward the window it has, and the window opened for the launch goes the same way the moment it does, without waiting this long. The view goes to the window it brought forward, on its own workspace (activate in docs/modes.md, and relaunching_an_application_whose_window_is_on_another_workspace_keeps_it_there_and_brings_it_into_view).

The default:

patience = 8000,

loading.reserves_a_slot

Whether it takes its place in the layout straight away. With this off, the other windows only move aside once the application is really there – less eager, and some people will prefer it.

The default:

reserves_a_slot = true,

loading.decorated

Whether the frame is drawn while it waits. The room it takes is reserved either way, so the window does not change shape when the application arrives; this only decides whether the bar is on screen meanwhile. On, and you get a close button for an application that is not coming. Off, and the scene has the whole window.

The default:

decorated = false,

loading.fade

How long the scene takes to fade off the application that replaced it, in milliseconds. It is drawn over the window, so what is underneath is already the application. 0 cuts straight to it.

The default:

fade = 180,

resize

What a window looks like while you are dragging its edge.

A client cannot be resized; it can only be asked, and it answers when it gets round to it – a few milliseconds for a terminal, rather more for a browser. The compositor no longer waits for that answer: the rectangle you are dragging is what it draws, from the first frame, and the client’s last picture fills it until the real one arrives. This decides what that filling looks like, which is entirely taste.

resize.fill

“stretch” scales the last picture into the new rectangle. Smooth, momentarily soft while a slow client catches up, and what most compositors do. The default.

“hold” leaves the picture at its own size where there is room for it, so nothing is resampled and what you see uncovered as the window grows is the pane underneath. Crisp instead of smooth. A window being made smaller is still scaled down, because a picture larger than the window it is in would spill over its neighbour.

“scene” draws the window’s QML scene over it – the same one a window wears before its application has painted. Today that only reaches a window resized before its application ever arrived; anywhere else it behaves as “hold”.

Whatever this says, a client that refuses the size it is offered – Firefox will not go under its minimum width – stops being stretched the moment it says so, and the window takes the size the client chose when you let go of the edge.

The default:

fill = "stretch",

fullscreen

A fullscreen window: what it covers, and how a window goes fullscreen and comes back (super+f, or the application asking).

fullscreen.covers

What a fullscreen window covers, when it is the one in front on the workspace its monitor is showing.

“top”, the default: the top layer, so a bar – a client’s, like Waybar, or one declared with sol.surface – goes under a fullscreen video or game, and the video takes the clicks where the bar was. The overlay layer stays over it: notifications, an OSD, a launcher. A window going fullscreen goes over the bars as it starts to grow, and one leaving goes back under them once it has finished shrinking.

“none”: nothing, and the bars stay over fullscreen windows.

Anything else is named in the log, and the default kept.

The default:

covers = "top",

fullscreen.animate

How a window grows to cover its monitor, and shrinks back to where it was: from wherever it is drawn, as a window a layout moves glides, so pressing the key again half way turns it round where it is. The application is told its new size the moment the key is pressed, and its last picture is stretched until it has drawn one at that size: through the glide and after it, for a quarter of a second past the landing at most, and then the window is shown at the size the application has. Once it has answered the window is drawn as it is, with nothing in between, so a game or a video can be shown directly.

duration is in milliseconds and easing is a curve, by name or as four numbers, as for every motion here (docs/animation.md). false makes every change instant – nothing is moved or stretched, and the window is drawn as it is on the next frame – and true is the motion below again:

fullscreen = { animate = false },

Change this default and SHIPPED in lua/fullscreen.lua with it: that is the motion animate = true turns back on.

The default:

animate = { duration = 260, easing = "outCubic" },

fullscreen.instant

Applications that go fullscreen and back at once whatever animate says, by their app id: the name app_id gives each window in sol.windows(). A game, or a video player started fullscreen, may want to be there at once:

fullscreen = { instant = { app_id = { "mpv", "gamescope" } } },

Empty as shipped, so every application animates. A list is replaced whole, so the list you write is the whole list.

The default:

instant = { app_id = {} },

maximize

How a window is maximised to fill the work area of its monitor, and restored (super+shift+m, or the button on its frame). The same two settings as fullscreen’s and read the same way, but on their own: a maximise can move differently from a fullscreen, or not at all, and changing one changes nothing about the other.

maximize.animate

How a window grows to fill the work area and shrinks back, as fullscreen.animate says for going fullscreen: a duration and an easing, false for instant, or true for the motion below.

maximize = { animate = false },

Change this default and SHIPPED in lua/fullscreen.lua with it.

The default:

animate = { duration = 220, easing = "outCubic" },

maximize.instant

Applications that are maximised and restored at once whatever animate says, by app id, as fullscreen.instant lists them.

The default:

instant = { app_id = {} },

floating

floating.client_limits

Whether a floating window’s edge drag stops at its application’s own minimum and maximum size (#115). The layouts’ say in the matter is tiling.client_minimum and tiling.client_maximum; this one is the compositor’s, because no layout is asked about a floating drag.

“respect”, the default: the frame you drag never shows a size the application is about to refuse. “ignore”: the drag goes wherever the pointer does, as before #115, and an application that refuses the size is treated as the end of resize above says. An application in tiling.client_size_ignore is not believed here either. Anything else is read as “respect”.

The default:

client_limits = "respect",

tiling

tiling.split

Where a split falls, as a share of the window being divided. Hyprland calls this dwindle:default_split_ratio.

The default:

split = 0.5,

tiling.minimum

The smallest a tile may be, frame included, in logical pixels.

A new window that would make a tile smaller than this goes where overflow says instead. And a seam cannot be dragged, or moved from the keyboard, to make a tile smaller than this. A tile that is already smaller does not jump when you grab it, and cannot be made smaller still; it can be grown only while the tiles across the seam have room to give. So a seam with a tile under this on both sides does not move at all – and that is what "allow" below usually makes, since it halves a tile too small to split.

A tile and not an application’s own minimum size: a window you launch from here is given its tile the moment you ask for it, before its application has started, so there is no application yet to ask. An application’s own minimum is client_minimum below.

0 on a side is no minimum on that side.

The default:

minimum = { w = 160, h = 96 },

tiling.client_minimum

Whether an application’s own minimum size gets a say (#115). Some applications will not go under a size – Firefox has a minimum width – and say so. You have the last word, not the application: this is where you give it.

“respect”, the default: a window is laid out at least as large as its application’s minimum wherever the arrangement has the room. It is given a larger share, taken from the windows beside it down to their own minimums and minimum above, and the share goes back when the application’s minimum does. A new window that would crowd one goes where overflow says. A seam stops where a window beside it reaches its minimum. And in the scrolling layout, a column is never narrower than the widest minimum in it.

Where the room is not there, the window is laid out smaller anyway and its application’s picture is cut to the tile. One line in the log says so, with the numbers, and the window is cramped = true in sol.windows() until it has room: docs/modes.md has a bar that shows it.

“ignore”: laid out as though no application had a minimum, which is how tiling behaved before #115. Anything else is read as “respect”.

The default:

client_minimum = "respect",

tiling.client_maximum

What a window whose tile is larger than its application’s maximum size does with the rest of the tile.

“center”, the default: it is its maximum size, in the middle of the tile. Dragging its edge still moves the seam, from the tile’s edge. “ignore”: it sits in the tile’s top-left corner at the size its application chose, as before #115. Anything else is read as “center”.

The default:

client_maximum = "center",

tiling.client_size_ignore

Applications whose sizes are not believed at all, by app_id: a Wayland application’s own, or an X11 one’s WM_CLASS class. sol.windows() lists each window’s. A window of one of these is laid out as though its application had said nothing, minimum and maximum alike, and a floating drag of it is not held to them either – for an application that claims a size it does not mean. For instance { “steam”, “org.gnome.Nautilus” }.

The default:

client_size_ignore = {},

tiling.overflow

Where a new window goes when splitting the tile under the pointer would make a tile smaller than minimum – side by side and one above the other, since a tile with no room one way may have it the other. Tried in order; the first with room wins.

  • "largest": split the largest tile on this workspace that has room for it
  • "workspace": open it on the next empty workspace of the monitor it opened on. The workspaces are the fixed set workspaces describes, so when none is empty this step does nothing and the next one runs. A window that belongs to no workspace in particular is on all of them, and with workspaces.follow_new_windows off that is every window – so then none is empty.
  • "allow": split the tile under the pointer anyway, below minimum

A list that runs out without placing the window – one with no “allow” – ends in “allow” all the same, with a line in the log: a window has to go somewhere. Put “workspace” first and a window goes straight to the next empty workspace once the tile under the pointer is full; leave “workspace” out and no window ever changes workspace for want of room.

Only a window being opened overflows, and only while tiling is the layout in charge. A window already open that goes back into the arrangement – tiling switched on, a reload, a monitor change, a close the application refused – never leaves its workspace for this: it takes a tile with room if there is one and a tile under minimum if there is not. A refused window and a dropped one go back to the tile where they were, whatever its size.

The default:

overflow = { "largest", "workspace", "allow" },

tiling.follow_overflow

Whether the view goes with a window that opened on another workspace, the way super+<n> takes it there. The keyboard goes to the window – for one launched from a binding, once its application has put up its window; until then it is not on the new window, and can still be on the one you were working in, a screen away by then.

false opens it there and leaves you, keyboard and all, where you are. Each window with no room then goes to another empty workspace, since the one the window before went to is no longer empty – whether or not it has room – until none is left empty.

The default:

follow_overflow = true,

tiling.motion

The default:

motion = { duration = 240, easing = "outCubic" },

tiling.snap

The shorter feel for a window snapping back after a drag.

The default:

snap = { duration = 180, easing = "outCubic" },

tiling.reflow_on_close

When the other windows close up around one you close.

“immediate”, the default: the moment you close it. The window fades out where it stood, in front of its neighbours as they grow into its space. If the application refuses to go – an unsaved-changes prompt – the window comes back split off whichever window now covers where it was, which is its old neighbour when nothing else has moved, at split rather than the ratio it had.

The compositor waits a second for an application to go before it takes the silence for a refusal. One slower than that to quit – some Electron and Java applications, a browser saving its session – is put back and then goes, so its neighbours grow, shrink back and grow again. “when_gone” moves them once.

“when_gone”: once the application has actually quit. Its tile stays reserved for the length of the fade and for however long the application then takes, and a refusal has nothing to put back. This is how every close behaved before #128.

Either way, only while tiling is the layout in charge.

Anything else is read as “immediate”.

The default:

reflow_on_close = "immediate",

tiling.move

What a keyboard move (super+shift+arrows, #150) does with the window it moves towards: the tile wholly past it that way and level with it, never one off on a diagonal.

“swap”, the default: the two trade tiles, and every split keeps its axis and its ratio. Where the tile it left is the one that way from the tile it took, as in a grid, the opposite move puts both back. niri moves windows this way; sway and Hyprland reshape the tree instead, which is what a dragged seam does not survive.

“split”: the window leaves its tile and splits the neighbour’s across that tile’s longer side, as a window opening in it would, as Hyprland’s movewindow does. Pressed again, it goes on into the next one.

At the edge of a monitor either way goes onto the next monitor, in beside the tile it arrives at. Anything else is named in the log and read as “swap”. with_tiling_move_split_a_move_goes_into_the_neighbours_split, in_tiling_focus_and_move_reach_the_neighbour_in_each_direction and in_tiling_a_tile_off_on_a_diagonal_is_not_that_way.

The default:

move = "swap",

scrolling

scrolling.widths

The widths a column cycles through with super+r, as shares of the view.

Read by sol.layout.scroller since #117. Before that the strip used the constant list in crates/layout/src/scroller.rs and these two lines were a description of it – so a configuration asking for quarters got thirds, and nothing anywhere said which of the two had been believed.

An entry that is not a share of a view – zero, negative, above 1, not a number at all – is dropped with a line in the log, and a list with nothing usable left falls back to this one. Refused rather than clamped, for the reason cursor.size is: a column at 0.001 of the screen is as unusable as no column, and nobody meant to ask for one.

The default:

widths = { 1 / 3, 1 / 2, 2 / 3 },

scrolling.default_width, scrolling.motion

Which of them a new column opens at, and where the cycle starts. 1-based, the way Lua counts. Past the end of the list is the last width, with a line in the log.

The default:

default_width = 1,
motion = { duration = 260, easing = "outCubic" },

scrolling.snap

The shorter feel for bringing a column into view.

The default:

snap = { duration = 200, easing = "outCubic" },

scrolling.reflow_on_close

When the strip closes the gap a window you close leaves: the moment you close it (“immediate”, the default), or once the application has actually quit (“when_gone”, how it was before #128). A window whose application refuses to go comes back in a column of its own after the window that was before it; one that was first comes back first. Anything else is read as “immediate”. The same setting as tiling.reflow_on_close, kept apart so the two layouts can be set differently – and with the same cost for an application slower than a second to quit, which closes the gap, opens it and closes it again.

The default:

reflow_on_close = "immediate",

workspaces

workspaces.per_monitor

Whether each monitor has its own active workspace.

On, super+2 switches the screen the pointer is on and leaves the other showing whatever it was: a reference on the second monitor stays put while you move around on the first. This is what sway, Hyprland and niri do, and what most people expect.

Off, one switch moves every screen at once, so a workspace is a whole desk rather than a screenful. That is GNOME’s model, and it is the right one if you think of your two monitors as one surface you happen to have cut in half.

Only matters with more than one monitor.

The default:

per_monitor = true,

workspaces.arrangement, workspaces.columns, workspaces.rows

“horizontal”: workspaces sit in a row and slide sideways. “vertical”: a column, sliding up and down. “grid”: both, columns wide and rows tall.

The arrangement decides which way a switch travels, and that is the whole difference between the three: a workspace to the right of this one enters from the right, because that is where it is.

It also decides how many there are: columns in a row, rows in a column, columns times rows in a grid – so four, as shipped.

The default:

arrangement = "horizontal",
columns = 4,
rows = 2,

workspaces.spread, workspaces.motion

How far apart workspaces sit, as a fraction of the screen. Above 1.0 there is a gap of empty space between them mid-slide, which reads as distance rather than as a cut.

The default:

spread = 1.06,
motion = { duration = 300, easing = "outCubic" },

workspaces.follow_new_windows

Whether a new window joins the workspace you are looking at.

The default:

follow_new_windows = true,

There is no dock.

A dock section sat here offering items and morph, and nothing in the compositor or in any script had ever read either of them – lua/init.lua says as much in as many words, where the genie’s target rectangle is a hardcoded strip of screen “because there is no dock yet”. Removed by #117 rather than wired up, because there is nothing to wire it to: a setting that configures a component which does not exist cannot be told apart, from out here, from one that is broken. When a dock arrives it brings its settings back to this spot. Until then --check reports the old spelling as an unrecognised key, which is the truth.

open

open.motion

The animation a window arrives with.

These read 200 / outCubic / 0.92 until #117, and open.lua had never looked at them: it held 220 / outBack / 0.88 as local constants, so what was written here described an animation nobody had ever seen. Wiring the two together meant choosing which pair was the default, and the script’s won – those are the numbers the animation was actually tuned against and the ones every session so far has been watching. Taking the advertised pair instead would have changed how every window on every machine opens, as a side effect of correcting a comment.

The default:

motion = { duration = 220, easing = "outBack" },

open.scale

How small it starts, as a share of the size it ends up. outBack overshoots, so it grows a little past that and settles back.

The default:

scale = 0.88,

genie_spread

How much of a window trails behind it in the genie, for the Developer Tweaks panel’s version of that effect (--debug-mode only).

Declared here because tweaks.lua already reads config.genie_spread and falls back to this number. An undeclared key that works is the same fault as a declared one that does not, read from the other side: nothing tells you it is there, and the unrecognised-key report below would call a working setting a typo.

The default:

genie_spread = 1.4,

How user.lua is read

The rest of config.lua is the code that merges your file over the defaults above and reports what it could not have meant. Its comments are the explanation; the code is folded under each.

A list is a table with a [1]; anything else with keys is a section to descend into. Crude, and right for every shape in this file.

The code
local function is_list(value)
    return type(value) == "table" and value[1] ~= nil
end

Sections whose key set is not the one written above them.

merge reports every key a user’s file sets that these defaults do not define – which is what catches tilling = { ... }. Three sections would be reported wrongly by that rule, and each needs saying out loud rather than being quietly skipped:

  • keyboard, cursor: their xkb and cursor keys are left out on purpose, because leaving them out means “whatever the session already said” – see their comments. Those key sets belong to sol.keyboard and sol.cursor_theme in the compositor, so they are written out here: the defaults cannot carry them without changing what leaving them out means. keyboard.indicator is the one key the defaults do define, and is checked against them.
  • bindings: open by construction. A key combination is whatever you press, so there is no list to check one against.

This table is checked against the compositor, and was wrong when it shipped. #117 wrote here that a reader growing a key and this list not being updated to match would report that key as a typo, and called that failure loud. It was not loud at all: active was missing from the day this was written, sol.keyboard has always read it, init.lua passes it straight through, and so keyboard = { layout = "us,ru", active = 2 } – the only shape of configuration that has an active worth naming – was told its working setting is read by nothing, and solium --check exited 1 over it. Which is worse than the silence #117 replaced: --check is sold as “did my configuration work”, and one wrong answer there teaches people to stop reading it.

A hand-restated list drifts, so a comment asking the next person to remember is not the fix. every_key_a_section_accepts_is_one_the_compositor_reads in script.rs hands each of these functions a table that records what is looked up in it and compares the two sets, in both directions: a key only the compositor reads is a working setting called a typo, and a key only this list has is #117’s own fault from the other side.

The code
local open_sections = {
    keyboard = {
        rules = true,
        model = true,
        layout = true,
        variant = true,
        options = true,
        repeat_rate = true,
        repeat_delay = true,
        -- Which of several layouts is live. `sol.keyboard{ active = 2 }` is
        -- also how the cycle binding in `init.lua` switches it.
        active = true,
        -- Caps Lock and Num Lock, on or off. `sol.keyboard{ caps = false }`
        -- from a binding turns Caps Lock off.
        caps = true,
        num = true,
    },
    cursor = { theme = true, size = true, scene = true },
    -- `true` rather than a set of names: everything is accepted.
    bindings = true,
}

The one key a user may write that these defaults deliberately do not define. See the read-across at the bottom of this file.

Accepted, and deliberately never suggested: nearest is not shown this table. It used to be, and so decoraton was answered with “did you mean decoration”, which sends somebody to correct a typo into a spelling that is deprecated – past the key that actually configures this, which is pane. An alias exists so a file written before the rename keeps working, not so a new file can be steered into using it.

The code
local legacy = { decoration = true }

Keys the user’s file set that nothing here defines, as dotted paths.

The code
local unrecognised = {}

How many single-character edits turn one word into the other.

Plain Levenshtein, two rows. Cheap enough not to think about: it runs only on a key that is already known to be wrong, against a handful of candidates of a dozen characters each, once per configuration load.

The code
local function distance(from, to)
    local previous = {}
    for column = 0, #to do
        previous[column] = column
    end
    for row = 1, #from do
        local current = { [0] = row }
        for column = 1, #to do
            local substitution = previous[column - 1]
            if from:sub(row, row) ~= to:sub(column, column) then
                substitution = substitution + 1
            end
            current[column] = math.min(previous[column] + 1, current[column - 1] + 1, substitution)
        end
        previous = current
    end
    return previous[#to]
end

The nearest key that does exist, when one is near enough to be worth naming.

Two edits, which is tilling to tiling and scal to scale but not dock to pane. A suggestion that is wrong is worse than none: it sends somebody to rewrite a line that was not the problem.

The code
local function nearest(key, base, accepted)
    local best, best_distance = nil, 3
    local function consider(candidate)
        if type(candidate) ~= "string" then
            return
        end
        local apart = distance(key:lower(), candidate:lower())
        if apart < best_distance then
            best, best_distance = candidate, apart
        end
    end
    for candidate in pairs(base) do
        consider(candidate)
    end
    if type(accepted) == "table" then
        for candidate in pairs(accepted) do
            consider(candidate)
        end
    end
    return best
end

Merge over onto base, and say what could not have been meant.

path is where we are, for the report; accepted is the extra key set in force here, from open_sections – true for a section that accepts anything, a table of names for one whose names live elsewhere, nil for the ordinary case where base itself is the list of what exists.

legacy is read straight out of the upvalue rather than handed in as an accepted set, because the two are accepted for opposite reasons: an open_sections name is the current spelling and is worth suggesting, and a legacy name is the old one and is not.

Checking on the way in, before the assignment, because after it the key exists in base and the evidence is gone.

The code
local function merge(base, over, path, accepted)
    for key, value in pairs(over) do
        local where = path and (path .. "." .. tostring(key)) or tostring(key)
        -- `legacy` is consulted here and not passed to `nearest`: an old
        -- spelling still works, and is still not what to suggest. Only at the
        -- top level, which is where the alias is.
        local known = accepted == true
            or base[key] ~= nil
            or (accepted and accepted[key])
            or (path == nil and legacy[key])
        if not known then
            local meant = nearest(tostring(key), base, accepted)
            -- Qualified the same way the key is, so the suggestion is something
            -- you can paste: inside `tiling` the fix is `tiling.split`, not
            -- `split`.
            if meant and path then
                meant = path .. "." .. meant
            end
            unrecognised[#unrecognised + 1] = { key = where, meant = meant }
        end
        if type(value) == "table" and type(base[key]) == "table"
            and not is_list(value) and not is_list(base[key]) then
            merge(base[key], value, where, open_sections[where])
        else
            base[key] = value
        end
    end
    return base
end

require searches the user’s directory first, so this finds ~/.config/solium/user.lua when there is one and nothing when there is not.

Having no user file and having a broken one both make require fail, and they must not be treated alike: a typo that silently changes nothing is the worst way to lose an afternoon. Only “no such module” is quiet; anything else is raised, so solium --check reports it and a reload keeps whatever was already running.

The code
local found, user = pcall(require, "user")
if found then
    if type(user) == "table" then
        merge(defaults, user)
    else
        error("user.lua must return a table, got " .. type(user), 0)
    end
elseif not tostring(user):match("module 'user' not found") then
    error(tostring(user), 0)
end

Say what was merged and will never be read.

merge used to validate nothing at all, so a user.lua saying tilling gained a section by that name, read by nothing, for ever – and solium --check, the one command whose job is answering “did my configuration work”, said it loaded fine. It had loaded fine. It was not doing what the file said, and there was no way to tell those two apart from the outside (#117).

Reported rather than refused. A key nothing reads has no effect by definition, so raising here would trade a setting that does nothing for a session that does not start – and on a reload, for a session that keeps the old configuration while you correct a spelling. sol.unknown puts each one in the log for a running session and in front of solium --check, which exits non-zero when there is one.

What this cannot see, stated rather than implied:

  • Lists are replaced whole and never descended into, so the keys inside a monitors entry are not checked here. mode misspelled as moed is merged as written and the monitor keeps its default mode.
  • keyboard, cursor and bindings are the sections above; the first two are checked against a list this file restates, the third against nothing.
  • A value of the wrong type is not this check’s business. gap = "12" is a recognised key and passes.
The code
for _, entry in ipairs(unrecognised) do
    sol.unknown(entry.key, entry.meant)
end

pane was called decoration when a style was a single QML file rather than a folder. Merging a user.lua that sets the old key leaves pane at its default, so without this the setting would silently stop working – which is the worst way to lose an afternoon, and the thing the alias exists to prevent. Read across only when the new name was not also given: someone who wrote both meant the one they had to look up.

The code
if found and type(user) == "table" and user.decoration ~= nil and user.pane == nil then
    defaults.pane = user.decoration
end

And the old key does not survive into the table the compositor reads. merge above copied it in, so without this the configuration carries both pane and a decoration that is no longer a setting – inert today, and exactly what a later pass validating pairs(config) would flag against a key the compositor itself wrote. Unconditional: the read-across above takes its value from user, never from here.

The code
defaults.decoration = nil

return defaults

Every setting

One row for each top-level setting in config.lua, and each links to where the configuration reference explains it. A setting is written in ~/.config/solium/user.lua, which holds only what you change:

return {
    gap = 4,
    tiling = { split = 0.618 },
}
SettingDefaultWhat it is
gap12Space between windows and around the work area, in logical pixels.
wallpaper"solium"The wallpaper.
shell{ … }A shell: the bar, the dock, the launcher. Keys: scene, on, outside_click, keyboard.
previewtrueThe compositor’s own preview shell: a bar, built as configuration on the same public API shell above describes (lua/preview/, qml/preview/; see docs/ricing.md).
keyboard{ … }The keyboard: its xkb layouts (layout, variant, options, model, rules, active), the locks (caps, num), the repeat rate, and the keyboard pill. Keys: indicator.
bindings{}The keys, and what they do.
cursor{}The pointer.
qml{ … }How QML – the frames, the pointer, the wallpaper – is rendered. Keys: renderer, probe_timeout.
monitors{}The monitors.
idle{ … }When nobody is at the machine. Keys: screens_off_after, off_frame_interval, dbus_inhibit.
session{ … }Telling the rest of the session that Solium is its desktop. Keys: systemd, autostart, stop_timeout.
pane"top"How every window is framed.
loading{ … }What a window does between being asked for and its application arriving. Keys: scene, patience, reserves_a_slot, decorated, fade.
resize{ … }What a window looks like while you are dragging its edge. Keys: fill.
fullscreen{ … }A fullscreen window: what it covers, and how a window goes fullscreen and comes back (super+f, or the application asking). Keys: covers, animate, instant.
maximize{ … }How a window is maximised to fill the work area of its monitor, and restored (super+shift+m, or the button on its frame). Keys: animate, instant.
floating{ … }Keys: client_limits.
tiling{ … }Keys: split, minimum, client_minimum, client_maximum, client_size_ignore, overflow, follow_overflow, motion, snap, reflow_on_close, move.
scrolling{ … }Keys: widths, default_width, motion, snap, reflow_on_close.
workspaces{ … }Keys: per_monitor, arrangement, columns, rows, spread, motion, follow_new_windows.
open{ … }Keys: motion, scale.
genie_spread1.4How much of a window trails behind it in the genie, for the Developer Tweaks panel’s version of that effect (--debug-mode only).

Key bindings

The 81 bindings the shipped configuration makes, as solium --check lists them, with where each is made and what it calls. None of them belongs to the compositor: each is a sol.bind call in a script, so any of them can be changed or removed from the bindings section of user.lua (how), and solium --check then lists what your configuration replaced or removed.

Two chords are the compositor’s own and are not listed: ctrl+alt+backspace ends the session whatever the configuration says, except while the screen is locked, and ctrl+alt+F1 to ctrl+alt+F12 switch virtual terminal.

Nor is a key a mode binds only while it is up, because solium --check lists what the configuration binds as it loads: escape leaves the overview, and is bound only while the overview is open, so that the rest of the time it reaches the application with the keyboard.

init.lua

Solium’s default configuration.

KeysWhat it doesWhere
super+returnopen_terminal — Both Enters.init.lua:145
super+kp_enteropen_terminal — Both Enters.init.lua:146
super+qClose the focused window.init.lua:150
super+shift+qsol.quit() — Ending the session.init.lua:161
XF86AudioRaiseVolumerun("wpctl", "set-volume", "-l", "1.0", SINK, "5%+"), "laptop keys"init.lua:181
XF86AudioLowerVolumerun("wpctl", "set-volume", SINK, "5%-"), "laptop keys"init.lua:182
shift+XF86AudioRaiseVolumerun("wpctl", "set-volume", "-l", "1.0", SINK, "1%+"), "laptop keys"init.lua:183
shift+XF86AudioLowerVolumerun("wpctl", "set-volume", SINK, "1%-"), "laptop keys"init.lua:184
XF86AudioMuterun("wpctl", "set-mute", SINK, "toggle"), "laptop keys"init.lua:185
XF86AudioMicMuterun("wpctl", "set-mute", SOURCE, "toggle"), "laptop keys"init.lua:186
XF86MonBrightnessUprun("brightnessctl", "set", "5%+"), "laptop keys"init.lua:187
XF86MonBrightnessDownrun("brightnessctl", "set", "5%-"), "laptop keys"init.lua:188
XF86AudioPlayrun("playerctl", "play-pause"), "laptop keys"init.lua:189
XF86AudioPauserun("playerctl", "play-pause"), "laptop keys"init.lua:190
XF86AudioNextrun("playerctl", "next"), "laptop keys"init.lua:191
XF86AudioPrevrun("playerctl", "previous"), "laptop keys"init.lua:192
Printrun("sh", "-c", SHOT .. 'grim "$file"' .. CLIP), "laptop keys"init.lua:198
shift+Printrun("sh", "-c", SHOT .. 'area="$(slurp)" && grim -g "$area" "$file"' .. CLIP), "laptop keys"init.lua:199
super+shift+drequire("tweaks").toggle() — Show or hide the Developer Tweaks panel.init.lua:204
super+shift+kCycle the keyboard layout, when the configuration lists more than one.init.lua:218
super+shift+rsol.reload() — Read this file again, without ending the session.init.lua:230
super+mDev: the genie.init.lua:244
super+gA tilted window, to see the compositor draw one as geometry rather than as a rectangle.init.lua:271

direction.lua

Focus and move by direction (#150).

KeysWhat it doesWhere
super+leftsol.focus_direction("left") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:293
super+rightsol.focus_direction("right") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:294
super+upsol.focus_direction("up") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:295
super+downsol.focus_direction("down") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:296
super+hsol.focus_direction("left") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:297
super+lsol.focus_direction("right") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:298
super+ksol.focus_direction("up") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:299
super+jsol.focus_direction("down") — The arrows and vim’s h, j, k and l, as sway and Hyprland ship them: super to focus, super+shift to move.direction.lua:300
super+shift+leftsol.move_direction("left")direction.lua:302
super+shift+rightsol.move_direction("right")direction.lua:303
super+shift+upsol.move_direction("up")direction.lua:304
super+shift+downsol.move_direction("down")direction.lua:305
super+shift+hsol.move_direction("left")direction.lua:306
super+shift+lsol.move_direction("right")direction.lua:307
super+alt+ksol.move_direction("up")direction.lua:308
super+shift+jsol.move_direction("down")direction.lua:309

modes.lua

Which layout is in charge.

KeysWhat it doesWhere
super+shift+spacemodes.lua:141
super+fsol.toggle_fullscreen()modes.lua:149
super+shift+msol.toggle_maximize()modes.lua:152

overview.lua

Overview: every window scaled onto a grid.

KeysWhat it doesWhere
super+spaceoverview.toggle — Into the overview and out again; while it is up, Escape leaves it too.overview.lua:137

scrolling.lua

Scrolling: columns and a moving view, as niri does it.

KeysWhat it doesWhere
super+sscrolling.togglescrolling.lua:521
super+bracketleftview:focus_sideways(-1, options(on)) — Move between columns, and within one.scrolling.lua:524
super+bracketrightview:focus_sideways(1, options(on)) — Move between columns, and within one.scrolling.lua:525
super+ctrl+bracketleftview:move_column(-1, options(on)) — Move between columns, and within one.scrolling.lua:526
super+ctrl+bracketrightview:move_column(1, options(on)) — Move between columns, and within one.scrolling.lua:527
super+shift+bracketleftview:focus_vertically(-1) — Move between columns, and within one.scrolling.lua:528
super+shift+bracketrightview:focus_vertically(1) — Move between columns, and within one.scrolling.lua:529
super+commaview:consume() — Stack a window into this column, or push it back out into its own.scrolling.lua:532
super+periodview:expel(options(on)) — Stack a window into this column, or push it back out into its own.scrolling.lua:533
super+rview:cycle_width(options(on)) — Cycle the column through the preset widths.scrolling.lua:536

tiling.lua

Tiling: dwindle, as Hyprland does it.

KeysWhat it doesWhere
super+ttiling.toggletiling.lua:900
super+minusnudge("width", -0.05)tiling.lua:931
super+equalnudge("width", 0.05)tiling.lua:932
super+ctrl+minusnudge("height", -0.05) — Height on the same two keys, because the other axis was reachable before this change …tiling.lua:956
super+ctrl+equalnudge("height", 0.05) — Height on the same two keys, because the other axis was reachable before this change …tiling.lua:957

workspaces.lua

Workspaces, as a presentation.

KeysWhat it doesWhere
super+1 … super+dworkspaces.go(index)workspaces.lua:668
super+shift+1 … super+shift+9workspaces.send(index)workspaces.lua:671
super+ctrl+leftworkspaces.step(-1, 0)workspaces.lua:676
super+ctrl+rightworkspaces.step(1, 0)workspaces.lua:677
super+ctrl+upworkspaces.step(0, -1)workspaces.lua:678
super+ctrl+downworkspaces.step(0, 1)workspaces.lua:679

The Lua API

Everything the compositor puts in the sol table, transcribed from sol.lua, the definitions file lua-language-server reads for completion (how to set it up). A test fails when the compositor and that file disagree about any of its 98 names.

Most calls queue a command and return nothing. The commands a handler queues are applied together when it returns, in the order it queued them, and every animated one uses the timing sol.animate last set in that handler. Desktop modes is the guide to using them.

Events

What sol.on can listen for.

EventHandler arguments and meaning
openA window’s life began: (id).
focusThe keyboard moved to a window: (id).
activateA window asked to be brought forward, after the compositor answered: (id, why). why is "launch" when an application already running answered a launch with a window it had, and "request" for anything else.
closingA close was asked for and the window is fading: (id).
refusedIts application declined, and the window is back: (id).
closeThe window is gone: (id).
fullscreenA window went fullscreen, or left it: (id, entering). Told once the change is made: its client has been sent its new size and it lives at its new rectangle. Once every listener has run, the compositor moves its picture there from where it is drawn, as a layout’s glide does, with the timing a listener set with sol.animate, and at once if none set one. Until the client answers at its new size the window is held there and its last picture stretched into it, for a quarter of a second past the landing at most; the transform is released when the glide lands. With no timing the change puts no transform on the window and holds nothing. A window a mode is presenting is left where the mode draws it. A change a listener makes is made at once and not told.
maximizeA window was maximised, or restored: (id, entering). Told and answered as "fullscreen" is, and on its own, so the two can move differently.
dropA dragged window was let go: (id, x, y).
resizeAn edge is being dragged: (id, edge_x, edge_y, horizontal_side, vertical_side).
scrollThe wheel turned with Super held: (dx, dy).
clickA press while a script holds input: (x, y).
surfaceA hosted scene sent an action: (surface, action, data), data a table, a value or nil. Every action is heard, in the order sent, in a dispatch after the one that sent it; a scene that sets an action string property is heard the same way, with data nil.
directionsol.focus_direction or sol.move_direction was called: (verb, dir), verb being "focus" or "move". The layout in charge answers.
layoutArrange the windows you already hold again: ().
monitorsThe monitors changed, or were announced at startup or after a reload: ().
restoreThese scripts replaced a running session’s, after a reload and never at startup: ().
text_inputThe focused text field changed: (field, why), field being what sol.text_input() answers at that moment and why what changed – "field", one was enabled, or focused again by its window getting the keyboard back; "caret", its client moved its caret; "framed", its window started or stopped being drawn in a frame, as one going fullscreen does. Told at most once a pass of the event loop, before the frame is drawn, as the most that changed: a caret moved twice since the last frame is told once, where it is now, and a field enabled and then given its caret, as kitty gives it with its first key, is told once as "field". A window moving with its field in it is not told. A field disabled, or its window losing the keyboard, is not told: sol.text_input() answers nil from then on, and field is never nil here.
keyboardThe live layout, Caps Lock or Num Lock changed, by a key or by sol.keyboard{ ... }: (state, changed), state being what sol.keyboard() answers now and changed "layout", "caps" or "num". Never for ordinary typing, a new keymap or a configuration starting, nor while the session is locked: what is pressed at the lock screen is the lock screen’s, and a lock screen that wants a Caps Lock warning draws its own. A change a keyboard listener makes is not told back to it.

sol

sol.keep(name, defaults)

State that survives super+shift+r.

Answers the table the last configuration kept under name, or defaults on the first load. Mutate it in place and the next reload gets what you left in it. It may hold numbers, strings, booleans, tables of those, and the trees and strips sol.layout makes, eight tables deep; anything else is named in the log and left out. Asked for twice under one name, it is the same table both times.

Parameters:

  • name: string
  • defaults: table

Returns table.

sol.lua:319

sol.windows()

Every window a layout may arrange, topmost first.

A snapshot taken for this handler, not a live view. A window that closes while you hold its id is ordinary: place and present on an id that no longer exists do nothing.

Returns sol.Window[].

sol.lua:327

sol.apps()

Every installed, visible application (NoDisplay and Hidden entries are left out at the source), by name.

A snapshot taken for this handler, not a live view, and – unlike sol.windows() and sol.monitors() – empty at the top level of a configuration file: it reads before the first real one arrives, and before the index has even scanned once on a first start. A scene reads Solium.Apps instead, which is live from the moment it is drawn.

Returns sol.App[].

sol.lua:338

sol.surface(name, options)

Draw a QML scene: a wallpaper, a bar, a dock, a heads-up display.

Declaring a name again with the same scene changes the live scene: changed properties are written into it, and on a monitor it was already on a new on, layer or interactive keeps it, so what the scene holds survives (an open popup, a running animation). A monitor it newly covers gets a scene built from the current properties, and one it leaves drops its scene. Only another scene file builds it again. A key left out of properties keeps the value the scene last had. A dotted key (["panel.open"] = true) reaches a grouped property. false or nil takes the surface away. Each monitor it is on gets a scene of its own, built when the surface is declared or the monitor arrives and dropped when the monitor goes; Solium.monitor inside it is that monitor. A scene file that is not there is logged, and built once it is there, at the next reload, change of monitors or changed declaration; a scene that will not load is logged once per monitor and not tried again until another scene is declared or the configuration is reloaded.

Parameters:

sol.lua:359

sol.keyboard(options)

Read the keyboard, or change it.

With no argument, answers the layouts, which one is live, its short name, whether Caps Lock and Num Lock are on, and how keys repeat. With a table, changes what it names and leaves the rest alone, so a binding that switches layout does not reset the repeat rate. sol.on("keyboard", ...) hears the layout and the locks change.

Also called as:

Parameters:

Returns sol.KeyboardState|nil.

sol.lua:371

sol.text_input()

The focused text field and where its caret is, or nil when the window with the keyboard has no text field enabled.

Kept by the text-input-v3 protocol: an application with a text field focused says so, and says where its caret is. Applications that do not speak it, and X11 ones, never have one. sol.on("text_input", ...) hears a field being enabled or focused, its caret moving, and its window being framed or left bare.

Returns sol.TextField|nil.

sol.lua:382

sol.monitors(rows)

Read the monitors, or arrange them.

With no argument, answers every monitor. With a list, drives and places the monitors it names; a row with no name is logged and skipped.

Also called as:

Parameters:

Returns sol.Monitor[]|nil.

sol.lua:391

sol.workspaces(declared)

Say what the workspaces are. The compositor does not know what a workspace is: workspaces.lua is the whole feature, and this is how a hosted shell’s Workspaces model learns it. Declare again whenever it changes; the same declaration twice costs nothing. Monitors and windows the compositor does not have are left out, with any group left with no monitor, and so is a workspace whose <group>/<id> an earlier one in a group that stays already has; each is logged once.

Parameters:

sol.lua:423

sol.monitor_power(which, mode)

Turn a monitor’s display off or on, or every monitor’s with "all".

The monitor keeps its place, its work area and its windows. Any key, click, scroll, touch or pointer motion turns every screen back on. A mode that is neither "on" nor "off" is an error; a name no monitor has is a line in the log.

Parameters:

  • which: string — A monitor’s name, or "all".
  • mode: "on"|"off"

sol.lua:434

sol.idle(options)

Set the idle blank: config.idle, which the shipped init.lua hands over.

Parameters:

sol.lua:439

sol.monitor(id)

The work area of the monitor a window is on, or of the active monitor when no window is named or the id is unknown.

Parameters:

  • id: integer (optional)

Returns sol.Rect.

sol.lua:445

sol.cursor()

Where the pointer is.

Returns sol.Point.

sol.lua:449

sol.window_at(x, y, skip)

The window drawn at a point, asked the way the compositor asks it: against where windows are drawn, on the monitors that draw them.

skip leaves one window out, which is what makes it useful while dragging: the dragged window is always the one under the pointer.

Parameters:

  • x: number
  • y: number
  • skip: integer (optional)

Returns integer|nil (id).

sol.lua:460

sol.present(id, options)

Draw a window somewhere other than where it lives, animated.

A transform: the window keeps its place and its client is told nothing, so sol.present_clear puts it back exactly. Input follows the rect it is drawn at.

A present that lands exactly on the window’s own frame (no rect, no rotation, full opacity) lets the window go, as sol.present_clear does, rather than holding it there.

Parameters:

sol.lua:474

sol.present_from(id, options)

Draw a window at a rect and animate it to where it lives: every “appears from somewhere” animation. The rect is required.

Parameters:

  • id: integer
  • options: { x: number, y: number, w: number, h: number, opacity?: number }

sol.lua:481

sol.debug_mode()

Whether the compositor was started with --debug-mode or with SOLIUM_DEBUG_MODE set. The Developer Tweaks panel asks this.

Returns boolean.

sol.lua:486

sol.reload()

Read the configuration again, without ending the session.

A configuration that fails to load leaves the running one in place. See sol.keep and the restore event for what a reload carries.

sol.lua:493

sol.pane(name)

Choose how every window is framed, at once.

A name is a folder under ~/.config/solium/qml/panes or the shipped qml/panes, or a single file under ~/.config/solium/qml/decorations; a path is anyone’s; "none" draws no frame. SOLIUM_PANE, when set, wins over the name given here.

Parameters:

  • name: string (optional)

sol.lua:503

sol.pane_values(values)

Hand values of the configuration’s own to every layer of every pane.

Every layer’s root reads them as one object, values, which PaneStyle declares for an inline layer and a delegated layer declares itself (property var values: ({})): so a setting the configuration reads can reach the QML that draws by it, and the compositor never knows what the setting is. Each call merges its keys into what is there, so two scripts each handing their own keep both; a key, once given, keeps its last value until the configuration is reloaded, which starts with none. Values are what sol.surface’s properties take. A window opened later is told them too.

Parameters:

  • values: table

sol.lua:522

sol.decorations()

Every frame style sol.pane could be given, found in the folders the compositor looks in: sorted, bundles first, each name once.

Returns sol.DecorationEntry[].

sol.lua:527

sol.loading(options)

Set what a window does between being asked for and its application arriving.

Parameters:

sol.lua:532

sol.client_sizes(options)

Set whose own size limits a floating window’s edge drag is held to.

floating = "ignore" lets the drag go wherever the pointer does; anything else believes every application. ignore lists applications, by app_id, whose limits are never believed.

Parameters:

  • options: { floating?: "respect"|"ignore", ignore?: string[] } (optional)

sol.lua:541

sol.resize(options)

Set what fills a window while a resize drag is ahead of its client.

"stretch" (the default) scales the last picture; "hold" leaves it at its own size; "scene" draws the window’s QML scene, which today reaches only a window resized before its application painted. Another name is logged and the default kept.

Parameters:

  • options: { fill?: "stretch"|"hold"|"scene" } (optional)

sol.lua:551

sol.fullscreen(options)

Set what a fullscreen window covers.

"top" (the default) covers the top layer, so bars go under it; "none" leaves bars over it. Another name is logged and the default kept.

Parameters:

  • options: { covers?: "top"|"none" } (optional)

sol.lua:559

sol.cursor_theme(options)

Set the pointer’s XCursor theme, size and scene. Applied at once, so a reload is how a theme or a scene is tried, and a scene whose files changed is built again. Not sol.cursor, which says where the pointer is.

Parameters:

sol.lua:566

sol.qml(options)

Choose how QML renders, and how long the GPU trial may take.

Read once, when the compositor starts, before any scene does; a reload does not change it. --qml, SOLIUM_QML and SOLIUM_QML_GPU override it.

Parameters:

sol.lua:574

sol.session(options)

Say how this session is announced, and how long a signal to end waits.

Read once, when the compositor starts; a reload does not change it. Who is told: the session bus, when Solium is started as the session with solium --tty --session; nobody, for a --tty start by hand or a nested run, unless SOLIUM_SESSION_BUS names a bus to tell instead. The shipped init.lua calls it with config.session.

Parameters:

sol.lua:585

sol.present_clear(id)

Stop presenting a window: animate it back to where it lives.

Parameters:

  • id: integer

sol.lua:590

sol.group(name, selection)

Name a selection of windows, surfaces and monitors, so one transform can carry them together.

Declaring the name again replaces the membership and keeps the transform, so a mode that rebuilds its groups on every pass does not restart its own animation. false or nil takes the name away.

Parameters:

sol.lua:601

sol.present_group(name, shift, motion)

Move, fade or turn a selection as one.

Composes with each member’s own sol.present, so a window tilted inside a moving desk stays tilted within it. motion sets this call’s own timing; left out, it is whatever sol.animate set.

Parameters:

sol.lua:612

sol.present_group_clear(name, motion)

Animate a selection back to where its members live.

Parameters:

sol.lua:618

sol.animate(options)

Set the timing for every animated command queued after this one in the same handler. Left unset, it is 220 ms and "outCubic". In a "fullscreen" or "maximize" listener it is also how the window’s own move is timed; there, left unset, the move is instant.

Parameters:

sol.lua:626

sol.focus(id)

Give a window the keyboard.

Parameters:

  • id: integer

sol.lua:631

sol.toggle_fullscreen(id)

Make a window fullscreen, or give it back its place if it already is. With no id, the focused window; with nothing focused, nothing happens.

Parameters:

  • id: integer (optional)

sol.lua:637

sol.toggle_maximize(id)

Maximise a window to its monitor’s work area, or give it back its place if it already is. With no id, the focused window; with nothing focused, nothing happens.

Parameters:

  • id: integer (optional)

sol.lua:644

sol.focus_direction(dir)

Move the keyboard to the window beside the focused one. Every direction listener is told ("focus", dir), and the layout in charge answers (lua/direction.lua). Anything but the four directions is an error. The listeners’ time counts against the handler that called this, so one stopped at the deadline stops that handler too.

Parameters:

  • dir: "left"|"right"|"up"|"down"

sol.lua:653

sol.move_direction(dir)

Move the focused window in a direction: direction listeners are told ("move", dir). Tiling trades it with its neighbour, scrolling moves it within or between columns, and a fullscreen or maximised window moved to another monitor stays so there. Anything but the four directions is an error. The listeners’ time counts against the handler that called this, so one stopped at the deadline stops that handler too.

Parameters:

  • dir: "left"|"right"|"up"|"down"

sol.lua:663

sol.place(id, options)

Put a window where it lives: the layout’s call.

The client is told the size and asked to redraw. The rect is a tile unless tile = false: a client that will not shrink to it is cut to it rather than drawn over its neighbours. A rect is required.

Parameters:

sol.lua:673

sol.unplace(id)

Let a window go from its tile, when a layout stops arranging it. modes.use sends this for every window when the layout in charge changes.

Parameters:

  • id: integer

sol.lua:679

sol.close(id)

Ask a window to close. A request, not a kill: its application may refuse.

Parameters:

  • id: integer

sol.lua:684

sol.act(action, data, done)

Perform an action of the vocabulary: the same names a scene sends with Solium.send, windows.focus, windows.close, windows.fullscreen and windows.maximize, each with { id = <window id> }; the last two toggle. data is handed over as a surface’s properties are, so a table nested deeper than 64 or holding more than 65 536 values, or one that contains itself, is an error in the handler. Queued like every other request, and answered by done(ok, reason) once the compositor has acted, in a handler of its own; reason is "unknown-action", "unknown-window", "bad-data", for a windows.focus behind the lock "locked", or, for a windows.fullscreen or windows.maximize of an X11 window, which the compositor cannot send there, "unsupported". A window still loading is "unknown-window" to all but windows.close. A done that calls sol.act again is told again in the same dispatch, 16 rounds at most, and the rest at the next one. Answers the attempt’s id, one no earlier sol.act answered. A workspaces.* action is not the compositor’s: the file that keeps the workspaces answers it, with actions.override in lua/actions.lua. An override is a surface listener of its own, under the same deadline, so one stopped three times is taken out alone, and its action goes to sol.act again.

Also called as:

  • sol.act(action: "windows.focus"|"windows.close"|"windows.fullscreen"|"windows.maximize", data: { id: integer }, done?: fun(ok: boolean, reason?: string)) returns integer

Parameters:

  • action: string
  • data: any (optional)
  • done: fun(ok: boolean, reason?: string) (optional)

Returns integer (attempt).

sol.lua:712

sol.spawn(program, ...)

Start a program, with its arguments as separate strings: sol.spawn("foot", "-e", "htop").

The program gets the environment Solium was started with, not what Qt and the libraries it loads have written into Solium’s own since. On top of that: WAYLAND_DISPLAY naming this compositor; DISPLAY naming its own Xwayland, or removed when there is none; XDG_CURRENT_DESKTOP, which is Lilium unless the session named one; XDG_SESSION_TYPE=wayland when it is unset; and XDG_ACTIVATION_TOKEN and DESKTOP_STARTUP_ID, naming the window opened for it. It holds no file descriptor beyond stdin, stdout and stderr. stdin and stdout are /dev/null, and stderr is Solium’s.

Parameters:

  • program: string
  • ...: string

sol.lua:728

sol.which(program)

Whether a program can be found on PATH. A name with a / in it is checked as a path. Nothing is run.

Parameters:

  • program: string

Returns boolean.

sol.lua:734

sol.quit()

End the session. Ctrl+Alt+Backspace does the same and cannot be rebound.

sol.lua:738

sol.grab_input(grabbed)

Take input away from clients, or give it back.

While a script holds it, keys it has not bound are swallowed, pointer presses go to click listeners, and focus stops following the pointer. Release it when you leave, or nothing reaches a window again.

Parameters:

  • grabbed: boolean

sol.lua:747

sol.status(text)

Say which mode is in charge. The compositor keeps the text and logs a change at debug level, and a hosted shell reads it as Solium.status; nothing the compositor draws itself shows it.

Parameters:

  • text: string

sol.lua:754

sol.bind(combo, handler, note)

Bind a key combination to a function.

The combination is normalised, so super+shift+q and Shift+Super+Q are one binding. A later call for the same combination replaces the earlier one. While the session is unlocked, a bound combination never reaches the application with the keyboard. A key a mode needs only while it is up is bound when the mode starts and taken away with sol.unbind when it stops, as overview.lua does with escape. note is a short phrase saying where the binding came from, which solium --check prints beside it.

Parameters:

  • combo: string
  • handler: fun()
  • note: string (optional)

sol.lua:770

sol.bound(combo)

Whether a combination is bound, in whatever spelling it is given.

Parameters:

  • combo: string

Returns boolean.

sol.lua:775

sol.unbind(combo, note)

Take a binding away. The combination then reaches the application with the keyboard again. With a note, solium --check lists the combination as removed by the configuration.

Parameters:

  • combo: string
  • note: string (optional)

sol.lua:783

sol.unknown(key, meant)

Report a setting nothing reads. config.lua calls this for each key of a user.lua its defaults do not define; solium --check lists them and exits with 1. meant is a near-miss suggestion.

Parameters:

  • key: string
  • meant: string (optional)

sol.lua:791

sol.on(event, handler)

Listen for an event. Listeners are added, never replaced, and one that fails is logged while the others still run.

A listener runs under a 100 ms deadline: one that takes longer is stopped with an error and logged with the file and line it was written at and the file and line it was stopped at (for an actions.override, the listener actions.lua writes for it, and the override’s own line), the other listeners still run, and one stopped three times is taken out until the configuration is reloaded. The stops are counted by function, so a function listening for two events counts the stops of both, and from its third on is taken out of each event it is stopped in. Bindings, and each done of sol.act, run under the same deadline, and a stopped binding is logged the same way. A done is struck by function too, and one stopped three times is not called again until the configuration is reloaded; a done written inline is a new function at each sol.act, so it is stopped each time rather than taken out. What a done asked for in the run that was stopped is dropped. The compositor starts each one’s clock itself, so nothing a handler calls puts it off. pcall, xpcall and load hand the stop on rather than catch it, and xpcall’s message handler is not called for it. A __gc finalizer, and the __close of a to-be-closed variable in the function a stop interrupts, run where no hook does, so the deadline cannot stop a loop in either.

Also called as:

  • sol.on(event: "open"|"focus"|"closing"|"refused"|"close", handler: fun(id: integer))
  • sol.on(event: "activate", handler: fun(id: integer, why: "launch"|"request"))
  • sol.on(event: "drop", handler: fun(id: integer, x: number, y: number))
  • sol.on(event: "resize", handler: fun(id: integer, edge_x: number, edge_y: number, horizontal_side: "left"|"right"|nil, vertical_side: "top"|"bottom"|nil))
  • sol.on(event: "scroll", handler: fun(dx: number, dy: number))
  • sol.on(event: "click", handler: fun(x: number, y: number))
  • sol.on(event: "surface", handler: fun(surface: string, action: string, data: any))
  • sol.on(event: "direction", handler: fun(verb: "focus"|"move", dir: "left"|"right"|"up"|"down"))
  • sol.on(event: "layout"|"monitors"|"restore", handler: fun())
  • sol.on(event: "text_input", handler: fun(field: sol.TextField, why: "field"|"caret"|"framed"))
  • sol.on(event: "keyboard", handler: fun(state: sol.KeyboardState, changed: "layout"|"caps"|"num"))

Parameters:

sol.lua:830

sol.log(message)

Write a line to the compositor’s log, at info level.

Parameters:

  • message: string

sol.lua:835

sol.decoration

The old name for sol.pane, from when a style was a single QML file. The same function.

sol.layout

The standard arrangements, from crates/layout. The pure ones take a count or a list and a sol.LayoutOptions and answer rects; tree and scroller make an arrangement the script holds.

sol.layout.master_stack(count, options)

One large window and the rest stacked beside it.

Parameters:

Returns sol.Rect[].

sol.lua:885

sol.layout.tree()

A dwindle tree, held by the script that made it. Stateful because where a window lands depends on which window was split and where the pointer was.

Returns sol.Tree.

sol.lua:890

sol.layout.scroller(options)

A scrolling strip of columns, held by the script that made it.

Reads widths and default_width from the table given, which is meant to be config.scrolling; with none it uses the built-in widths.

Parameters:

  • options: { widths?: number[], default_width?: integer } (optional)

Returns sol.Scroller.

sol.lua:898

sol.layout.strip(columns, options)

A row of columns, each holding a stack, with options.offset as how far the view has scrolled.

Parameters:

Returns sol.Rect[].

sol.lua:905

sol.layout.strip_scroll_to(index, columns, options)

The view offset that brings column index (counting from 1) fully into view, moving as little as possible.

Parameters:

Returns number (offset).

sol.lua:913

sol.layout.scrolling(count, options)

A row of count windows at options.column of the width each, with options.offset as how far the view has scrolled.

Parameters:

Returns sol.Rect[].

sol.lua:920

sol.layout.scroll_to(index, count, options)

The view offset that brings window index (counting from 1) of count fully into view in sol.layout.scrolling.

Parameters:

Returns number (offset).

sol.lua:928

sol.layout.grid(sizes, options)

A grid of thumbnails, as close to square as the count allows. Each size keeps its aspect ratio inside its cell; overview uses this.

Parameters:

Returns sol.Rect[].

sol.lua:935

A dwindle tree

A dwindle tree from sol.layout.tree(). Every call that takes options reads options.floors first, and lays the tree out in options as the work area.

Tree:insert(id, target, x, y, options)

Add a window by splitting target, or the tile under x, y when there is no target. Splits however small that leaves the halves.

Parameters:

  • id: integer
  • target: integer (optional)
  • x: number (optional)
  • y: number (optional)
  • options: sol.LayoutOptions

sol.lua:953

Tree:insert_fitting(id, target, x, y, options)

insert, refusing a split that would leave a tile under options.minimum or a window under its floor: the same side first, then the other axis. Answers false and leaves the tree alone when neither fits.

Parameters:

  • id: integer
  • target: integer (optional)
  • x: number (optional)
  • y: number (optional)
  • options: sol.LayoutOptions

Returns boolean (inserted).

sol.lua:964

Tree:insert_largest(id, options)

Split the largest tile that has room for another window at options.minimum, or answer false and leave the tree alone.

Parameters:

Returns boolean (inserted).

sol.lua:971

Tree:remove(id)

Take a window out of the tree.

Parameters:

  • id: integer

sol.lua:976

Tree:resize(id, axis, by, options)

Move a seam from the keyboard: by is a signed share that grows the window when positive. The seam on the right or below is preferred, and the other one used only when there is none. Without options there is no minimum.

Parameters:

  • id: integer
  • axis: "width"|"height"
  • by: number
  • options: sol.LayoutOptions (optional)

sol.lua:986

Tree:drag_seam(id, edge, edge_x, edge_y, options)

Move a seam from a drag: put the named side of the window at edge_x (for "left" and "right") or edge_y (for "top" and "bottom"). Hand it what the resize event gave you. Clamped to 0.05..0.95 and to options.minimum.

Parameters:

  • id: integer
  • edge: "left"|"right"|"top"|"bottom"
  • edge_x: number
  • edge_y: number
  • options: sol.LayoutOptions

sol.lua:998

Tree:contains(id)

Whether a window is in the tree.

Parameters:

  • id: integer

Returns boolean.

sol.lua:1003

Tree:windows()

Every window in the tree, in tree order.

Returns integer[].

sol.lua:1007

Tree:swap(a, b)

Trade the tiles of windows a and b and change nothing else: every split keeps its axis and its ratio, so a second swap puts it back.

Parameters:

  • a: integer
  • b: integer

Returns boolean (both_were_in_the_tree).

sol.lua:1014

Tree:layout(options)

Where every window in the tree goes, ready for sol.place.

Parameters:

Returns sol.Slot[].

sol.lua:1019

A scrolling strip

A strip of columns from sol.layout.scroller(). Every call that takes options reads options.floors first: a column is never laid out narrower than the widest floor in it.

Scroller:insert(id, options)

Open a window in a new column, to the right of the active one.

Parameters:

sol.lua:1033

Scroller:insert_into_column(id, options)

Add a window to the active column instead of beside it.

Parameters:

sol.lua:1039

Scroller:remove(id)

Take a window out of the strip.

Parameters:

  • id: integer

sol.lua:1044

Scroller:focus_window(id, options)

Focus a window, bringing its column into view.

Parameters:

sol.lua:1050

Scroller:focus_sideways(by, options)

Move focus by columns left (negative) or right, taking the view with it.

Parameters:

sol.lua:1056

Scroller:focus_vertically(by)

Move focus by windows up (negative) or down within the active column.

Parameters:

  • by: integer

sol.lua:1061

Scroller:move_vertically(by)

Swap the focused window with the one above it in its column (by is -1) or below it (1), carrying focus along. Nothing moves at the top or bottom.

Parameters:

  • by: integer

Returns boolean (moved).

sol.lua:1067

Scroller:move_column(by, options)

Swap the active column with its neighbour by places away, carrying focus.

Parameters:

sol.lua:1073

Scroller:consume()

Pull the next column’s focused window into the active column.

sol.lua:1077

Scroller:expel(options)

Push the focused window out into a column of its own.

Parameters:

sol.lua:1082

Scroller:move_to_column_of(id, target, options)

Move a window into the column another window is in.

Parameters:

sol.lua:1089

Scroller:widen(id, by, options)

Widen (positive) or narrow the column a window is in, by a share of the view. Clamped so a column neither vanishes nor grows past the view.

Parameters:

sol.lua:1097

Scroller:cycle_width(options)

Cycle the active column through the preset widths.

Parameters:

sol.lua:1102

Scroller:configure(options)

Take widths and default_width from options as sol.layout.scroller does, keeping the columns, the focus and the view. For a strip sol.keep carried across a reload.

Parameters:

  • options: { widths?: number[], default_width?: integer }

sol.lua:1109

Scroller:scroll_by(delta)

Scroll the view by a distance, without moving focus.

Parameters:

  • delta: number

sol.lua:1114

Scroller:focused()

The focused window, if there is one.

Returns integer|nil.

sol.lua:1118

Scroller:contains(id)

Whether a window is in the strip.

Parameters:

  • id: integer

Returns boolean.

sol.lua:1123

Scroller:windows()

Every window in the strip, left to right and top to bottom within a column.

Returns integer[].

sol.lua:1127

Scroller:layout(options)

Where every window in the strip goes, ready for sol.place.

Parameters:

Returns sol.Slot[].

sol.lua:1132

Types

The shapes of the tables the calls above take and return.

sol.Rect

A rectangle in the one global coordinate space every monitor is a window onto, in logical pixels.

  • x: number
  • y: number
  • w: number
  • h: number

sol.Size

A size in logical pixels.

  • w: number
  • h: number

sol.Point

A point in the global coordinate space.

  • x: number
  • y: number

sol.Window

One row of sol.windows(): a window a layout may arrange, as it was at the moment the handler was called. x, y, w and h are where the window lives, frame included.

Has every field of sol.Rect, and:

  • id: integer — The window’s id, which every other call takes.
  • title: string — The window’s title; the program’s name until its application arrives.
  • focused: boolean — Whether it has the keyboard. While an item of a hosted scene holds the keyboard (Solium.keyboard.wants), the window it was taken from still reads true, is drawn focused, and gets the keyboard back when the scene lets go.
  • monitor: string — The name of the monitor it is on.
  • modal: boolean — Whether it says it is a modal dialog.
  • parent: integer|false (optional) — The window it belongs to; false when the client named a parent that is not on screen, absent when it named none.
  • leaving: boolean — Whether it is closing: between closing and close or refused.
  • app_id: string — The application’s own name: a Wayland app_id, an X11 WM_CLASS class. Empty until the application arrives.
  • min: sol.Size (optional) — The smallest the application says it can be, frame included; absent when it set no limit.
  • max: sol.Size (optional) — The largest the application says it can be, frame included; absent when it set no limit.
  • cramped: boolean — Whether the layout last placed it with cramped = true.
  • shown: boolean — Whether its application has shown its first frame yet.

sol.App

One row of sol.apps(): an installed, visible application.

  • id: string — The desktop file id, such as "org.mozilla.firefox".
  • name: string — Its localised name.
  • generic_name: string — Its localised generic name ("Web Browser"); empty when it named none.
  • icon: string — A theme icon name, or an absolute path when Icon= gave one.
  • categories: string[] — Its Categories=, split on ;.

sol.Monitor

One row of sol.monitors(). x, y, w and h are the work area: the monitor less what layer-shell bars and hosted surfaces reserve.

Has every field of sol.Rect, and:

  • name: string — The connector name, such as "DP-1".
  • scale: number — Device pixels per logical pixel.
  • focused: boolean — Whether the pointer is on it.
  • primary: boolean — Whether it is the primary monitor.
  • transform: string — Its rotation, as Smithay names it in lower case: "normal", "_90", "_180", "_270", "flipped", "flipped90", "flipped180" or "flipped270". Not the spelling sol.monitors{ ... } takes.
  • power: "on"|"off" — Whether its display is on. A monitor that is off keeps its place, its work area and its windows.
  • whole: sol.Rect — The whole monitor, which is what a wallpaper or a fullscreen window covers.

sol.MonitorPlacement

How one monitor should be driven and where it goes, for sol.monitors{ ... }. Every key but name is optional. config.lua’s monitors section explains each one at length.

  • name: string — The connector name. solium --probe lists the ones the machine has.
  • x: integer (optional) — The top-left corner, in the global space.
  • y: integer (optional) — The top-left corner, in the global space.
  • right_of: string (optional) — Beside the monitor with this name.
  • left_of: string (optional) — Beside the monitor with this name.
  • above: string (optional) — Beside the monitor with this name.
  • below: string (optional) — Beside the monitor with this name.
  • align: "start"|"centre"|"end" (optional) — Which way the other axis lines up when placed beside a monitor of another size. "centre" is the default.
  • mode: string|{ w: integer, h: integer, refresh?: integer } (optional) — "2560x1440@165", "2560x1440", or one of "best" (the default), "preferred" and "widest".
  • vrr: boolean (optional) — Variable refresh rate, where the monitor and the driver offer it.
  • transform: string|integer (optional) — Anticlockwise: "normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180" or "flipped-270". A number is read as degrees.
  • enabled: boolean (optional) — false does not drive the monitor at all.
  • primary: boolean (optional) — The monitor things belonging to one screen go on.
  • scale: number|"auto" (optional) — Device pixels per logical pixel, from 0.5 to 8. Left out, it is worked out from the panel’s size.

sol.KeyboardState

The keyboard as sol.keyboard() answers.

  • layouts: string[] — The layout names, in order.
  • active: integer — Which of them is live, counting from 1.
  • layout_name: string — The live layout’s name, such as "Russian"; "" before the keymap is known. Not layout, which sol.keyboard{ ... } takes as the xkb names to compile, so a table read here can be handed back.
  • layout_short: string — The live layout’s short name, such as "RU": the shortDescription xkb’s rules give it, upper-cased, or its first two letters.
  • caps: boolean — Whether Caps Lock is on.
  • num: boolean — Whether Num Lock is on.
  • repeat_rate: integer — Repeats per second.
  • repeat_delay: integer — Milliseconds a key is held before it repeats.

sol.TextField

The focused text field, as sol.text_input() answers: the window it is in, whether that window is framed, and its caret in the global space. The four numbers are there only once the application has said where its caret is, through text-input-v3; an application that never does has a field and no caret.

  • window: integer — The window’s id, as sol.windows() gives it.
  • framed: boolean — Whether the window is drawn in a frame of the compositor’s, or will be once its frame is built, so its pane style’s layers are drawn with it. False for a window drawn bare: fullscreen, one drawing its own decorations, every window under the pane style "none".
  • x: number (optional) — The caret’s rectangle, where it is drawn: its window’s place and scale on screen are in it, presentation transforms included.
  • y: number (optional)
  • w: number (optional)
  • h: number (optional)

sol.KeyboardOptions

What sol.keyboard{ ... } may change. Every key is optional and one left out is left as it is. The first five are xkb names; setting any of them compiles a new keymap, and one left blank falls back to the matching XKB_DEFAULT_* environment variable.

  • rules: string (optional)
  • model: string (optional)
  • layout: string (optional) — A comma-separated list, such as "us,ua".
  • variant: string (optional) — One per layout, in the same order.
  • options: string (optional) — xkb options, comma separated, such as "grp:alt_shift_toggle".
  • repeat_rate: integer (optional) — Repeats per second.
  • repeat_delay: integer (optional) — Milliseconds before repeating starts.
  • active: integer (optional) — Which layout is live, counting from 1.
  • caps: boolean (optional) — Caps Lock on or off. Done by pressing the keymap’s own Caps Lock key inside the compositor, so the window with the keyboard is told and the layout stays; a keymap with no such key cannot have it, and the log says so.
  • num: boolean (optional) — Num Lock on or off, the same way.

sol.SurfaceOptions

A QML scene for sol.surface to draw.

  • scene: string — A file name looked up in ~/.config/solium/qml and then in the shipped QML, or a path (absolute, or starting with ~/).
  • layer: sol.Layer (optional) — Which layer it is drawn in. "background" is the default.
  • on: "primary"|"every-monitor"|string|sol.Rect (optional) — "every-monitor" (the default) draws one instance per monitor, filling it; "primary" one on the primary monitor; a monitor’s name one there; a rect one at that rect.
  • properties: table (optional) — Values for the scene’s properties, handed over as JSON: strings, numbers, booleans and tables of those, nested at most 64 deep and 65 536 values in all, a table counted once for each place it is in. A function, userdata or non-finite number is left out; a table nested deeper or holding more, or one that contains itself, is an error.
  • interactive: boolean (optional) — Whether the pointer reaches it at all, so a Grab in a scene it does not reach holds nothing, and one it held when it is declared so is dismissed; nor does such a scene hold the keyboard. Where it does, the scene’s items decide which points are its (Solium.input), and the rest go to what is under it. An interactive scene sends actions with Solium.send(action, data), and sol.on("surface", ...) hears them.
  • reserve: { top?: integer, right?: integer, bottom?: integer, left?: integer } (optional) — Logical pixels taken out of the work area on those edges of every monitor the surface is on, whatever its size or placement; never negative. A scene’s own Solium.surface.reserve.<edge> wins for an edge it sets. A change re-flows the windows once.
  • outside_click: "swallow"|"pass"|table<string, "swallow"|"pass"> (optional) — What a press outside an open Grab of the scene does once it has dismissed it: swallowed with its release (the default), or passed on to what is under it. A table names grabs, with default for the rest. Any other value fails the load.
  • keyboard: { bindings?: "except_claimed"|"all"|"none" } (optional) — While an item of the scene holds the keyboard (Solium.keyboard.wants): "except_claimed" (the default) keeps every binding but the keys the holding item claims (Solium.keyboard.claims), "all" keeps every binding, and "none" gives the scene every key but the Ctrl+Alt escapes. Any other value fails the load.

sol.Layer

  • "background" — Under everything, including client background surfaces.
  • "bottom" — Above the background, below windows.
  • "top" — Above windows, below client top surfaces, and below a fullscreen window that covers the top layer.
  • "overlay" — Above client top surfaces, below client overlay ones and the pointer.

sol.Easing

A curve by name, or four numbers: the control points of a cubic bezier, as CSS writes cubic-bezier(x1, y1, x2, y2). An unknown name is logged and the default kept.

  • "linear"
  • "outCubic"
  • "outBack"
  • "inOutQuad"
  • "inOutCubic"
  • "spring"
  • number[]

sol.Motion

How long an animation takes and how it moves.

  • duration: integer (optional) — Milliseconds.
  • easing: sol.Easing (optional)

sol.PresentOptions

Where and how sol.present draws a window. A key left out says nothing: with no rect the window is drawn at its own, with no opacity it is opaque, and with no rotation it is flat.

  • x: number (optional) — The rect it is drawn at: all four of x, y, w and h, or none.
  • y: number (optional)
  • w: number (optional)
  • h: number (optional)
  • opacity: number (optional) — From 0 to 1.
  • rotate_x: number (optional) — Degrees about the horizontal axis.
  • rotate_y: number (optional) — Degrees about the vertical axis.
  • rotate_z: number (optional) — Degrees in the plane of the screen.
  • perspective: number (optional) — The viewer’s distance in pixels. Without it a rotation is orthographic.
  • pivot_x: number (optional) — What the rotation turns about, as a share of the width. 0.5 is the default; outside 0..1 is kept.
  • pivot_y: number (optional) — What the rotation turns about, as a share of the height. 0.5 is the default; outside 0..1 is kept.
  • z: number (optional) — Draw order and nothing else: higher is in front, equal keeps the stacking order, 0 is the default. Input still follows the rect.
  • deform: sol.Deform (optional) — A deformation of the window’s mesh.

sol.Deform

A deformation, named by effect. Its other keys are that effect’s parameters.

  • effect: "genie" — The effect, from crates/effects. An unknown one is logged and the window drawn undeformed.
  • to: { window: integer }|{ surface: string }|sol.Rect — What the window is pulled into or drawn out of. A window or a surface is followed as it moves; a rect stays where it is. A surface on several monitors is the one on the window’s own monitor.
  • axis: "down"|"up"|"left"|"right" (optional) — For the genie: which edge leads.
  • spread: number (optional) — For the genie: how much of the window is in motion at once. 0 is rigid.

sol.PlaceOptions

Where sol.place puts a window.

Has every field of sol.Rect, and:

  • tile: boolean|sol.Rect (optional) — true (the default) makes the rect a tile the window is held inside; false places it without tiling it; a rect is the tile, for a window placed smaller than its tile.
  • cramped: boolean (optional) — The layout saying the tile is smaller than the window’s own minimum. sol.windows() reports it back.

sol.Selection

What a selection holds, for sol.group. Every key is optional.

  • windows: integer[] (optional) — Windows, by id.
  • surfaces: string[] (optional) — Surfaces, by the name sol.surface gave them.
  • monitors: string[] (optional) — Everything drawn on these monitors.
  • monitor: string (optional) — Which instance of each surface above: the one on this monitor.

sol.Shift

What sol.present_group does to a selection. x and y are a displacement, not a destination, and the rest compose with each member’s own transform.

  • x: number (optional)
  • y: number (optional)
  • opacity: number (optional)
  • rotate_x: number (optional)
  • rotate_y: number (optional)
  • rotate_z: number (optional)
  • perspective: number (optional)

sol.LoadingOptions

What a window does before its application arrives. Every key is optional and one left out keeps the compositor’s default.

  • scene: string (optional) — The QML scene: a name in qml/loading, the user’s first, or a path.
  • patience: integer (optional) — Milliseconds to wait for an application that never arrives.
  • reserves_a_slot: boolean (optional) — Whether the window takes its place in the layout at once.
  • decorated: boolean (optional) — Whether its frame is drawn while it waits.
  • fade: integer (optional) — Milliseconds the scene takes to fade off the application.

sol.IdleOptions

The idle blank. A key left out, or one that is not a number of 0 or more, keeps the default.

  • screens_off_after: number (optional) — Seconds with nobody at the machine before every screen is turned off. 0 never does.
  • off_frame_interval: number (optional) — How often, in milliseconds, a window on a screen that is off is still told it may draw. 0 stops it.
  • dbus_inhibit: boolean (optional) — Own org.freedesktop.ScreenSaver, so a browser’s D-Bus inhibitor keeps the screens on like a Wayland one. Default true.

sol.CursorOptions

The pointer’s theme. A key left out, or no table, means the configuration did not say, and XCURSOR_THEME and XCURSOR_SIZE have their turn.

  • theme: string (optional) — The name of an XCursor theme.
  • size: integer (optional) — Logical pixels, from 8 to 256.
  • scene: string (optional) — A QML scene to draw the pointer with, for every shape and ahead of the theme: a path, ~ expanded, or a name looked for in ~/.config/solium/qml and then the shipped QML. SOLIUM_QML_CURSOR overrides it for one run.

sol.QmlOptions

How QML is rendered. Read once, before Qt starts.

  • renderer: "auto"|"gpu"|"software" (optional) — "auto" tries the GPU in a child process first.
  • probe_timeout: integer (optional) — Milliseconds the GPU trial may take.

sol.SessionOptions

Telling systemd and D-Bus that Solium is the session. A key left out, or one of the wrong kind, keeps the default.

  • systemd: boolean (optional) — Export the environment to systemd and D-Bus activation and start solium-session.target; undone on exit. true by default.
  • autostart: boolean (optional) — Start solium-autostart.target, XDG autostart, beside it. true by default.
  • stop_timeout: integer (optional) — Milliseconds SIGTERM, SIGINT or SIGHUP waits for a clean stop before Solium ends at once. 5000 by default.

sol.DecorationEntry

One entry of sol.decorations().

  • name: string — What sol.pane takes.
  • kind: "bundle"|"file" — A folder under qml/panes, or a single QML file under qml/decorations.

sol.LayoutOptions

What the layout calls take: the work area to arrange in, and how.

Has every field of sol.Rect, and:

  • gap: number (optional) — Space between windows and around the edge. 12 by default.
  • ratio: number (optional) — The share of the width the main window takes in master_stack. 0.6 by default.
  • column: number (optional) — The share of the width a column takes in scrolling. 0.5 by default.
  • padding: number (optional) — Space around each thumbnail in grid. 24 by default.
  • split: number (optional) — Where a dwindle split falls, as a share of the tile divided. 0.5 by default.
  • minimum: sol.Size (optional) — The smallest a dwindle tile may be. None by default.
  • floors: table<integer, sol.Size> (optional) — Each window’s own floor, by id, frame included.
  • offset: number (optional) — How far the view has scrolled, for strip, strip_scroll_to, scrolling and scroll_to.

sol.Slot

A rect a tree or a strip hands back, tagged with its window.

Has every field of sol.Rect, and:

  • id: integer
  • cramped: boolean (optional) — true on a tile smaller than its window’s own floor; trees only.

sol.Column

One column of a strip, for sol.layout.strip.

  • width: number (optional) — The column’s width as a share of the view. 0.5 by default.
  • windows: integer (optional) — How many windows share the column. 1 by default.

sol.DeclaredWorkspace

What one workspace is, in a sol.workspaces declaration.

  • id: string — Unique within its group.
  • name: string (optional) — What a shell shows; the id when left out.
  • col: integer (optional) — Its column in the arrangement, from 1.
  • row: integer (optional) — Its row in the arrangement, from 1.
  • hidden: boolean (optional) — A workspace a shell should not list, such as a scratchpad.

sol.WorkspaceGroup

A group of workspaces that switch together: one per monitor, or one for every monitor.

  • id: string
  • monitors: string[] — The monitors it is on, by connector name.
  • showing: string[] — The ids it shows now.
  • workspaces: sol.DeclaredWorkspace[]

sol.WorkspaceDeclaration

  • arrangement: { kind?: string, columns?: integer, rows?: integer } (optional) — The shape the workspaces make, for a shell to draw.
  • groups: sol.WorkspaceGroup[]
  • windows: table<integer, string[]> (optional) — Which workspaces each window is on, by window id.

Internal

Tables the compositor keeps in sol for its own use. They are listed so the list is complete; a script should use the functions named.

  • sol._bindings — Internal: the bindings, by normalised combination. Use sol.bind, sol.bound and sol.unbind.
  • sol._handlers — Internal: the listeners, by event. Use sol.on.
  • sol._attempts — Internal: each attempt’s done, by attempt id. Use sol.act.
  • sol._keeps — Internal: what sol.keep holds, by name.
  • sol._strikes — Internal: how many times each listener or done was stopped, by function.
  • sol._binding_sources — Internal: the notes sol.bind and sol.unbind were given, by combination.
  • sol._unknown_settings — Internal: what sol.unknown reported.

Flags and environment

Every command-line flag and SOLIUM_* variable the compositor reads, from environment.txt, with where the code reads each one. A test fails when the code reads one this page does not list.

Flags

Every argument is checked before anything starts, and one Solium does not know is refused with exit status 2. One of --tty, --check, --check-qml, --probe, --help and --version chooses what Solium does, anywhere on the line; with none it starts a compositor: nested, as a window, when WAYLAND_DISPLAY or DISPLAY is set, and on the hardware otherwise.

--help

Print how to run Solium, its flags and the variables that change a session, and exit. Also -h. Starts nothing.

Read at cli.rs:61.

--version

Print the version, and the commit it was built from when it was built from a git checkout (solium 0.0.0 (dfc95ce)), and exit. Also -V. Starts nothing.

Read at cli.rs:62.

--tty

Run on the hardware from a free virtual terminal, taking the screen, and write the session log, $XDG_STATE_HOME/solium/session.log (or ~/.local/state/solium/session.log).

Read at cli.rs:63.

--session

Only with --tty, anywhere on the line. solium --tty --session is the user’s graphical session, and tells systemd and D-Bus so: it exports WAYLAND_DISPLAY and the rest, starts solium-session.target, and undoes both on exit. solium-session, which the session file starts, runs it this way. Without it, --tty tells nobody unless SOLIUM_SESSION_BUS names a bus.

Read at cli.rs:81, session.rs:107.

--check

Load the configuration, print every binding it registered and every one it removed, build every scene it declares at load and every pane style of your own, and exit; also the pane style and the loading scene this run’s environment names. Exits with 1 when anything fails: the configuration fails to load or sets a key nothing reads, or a scene fails to build, misses a required property, or warns while it is built and first drawn. Scenes are built in software, on one monitor. --check <file.qml> checks one QML file instead, as --check-qml does. Starts no compositor and needs no display.

Read at cli.rs:65.

--check-qml <file.qml>

The old spelling of --check <file.qml>: load one QML file in software and, for a style’s Pane.qml, every layer it names; print what went wrong; exit with 1 when anything did. A style that requires the GPU is said to, and its layers are not loaded. Starts no compositor.

Read at cli.rs:71.

--probe

Print every connector and every mode it offers, without taking the screen. Safe to run inside another session.

Read at cli.rs:64.

--probe-qml-gpu

Internal. The child process the QML GPU trial runs in; see QML on the GPU.

Read at renderer.rs:24.

--debug-mode

Add the Developer Tweaks panel, on super+shift+d. Read anywhere on the command line.

Read at cli.rs:80, dev.rs:385.

--qml <mode>

Which scene graph QML renders on: auto, gpu or software. Also written --qml=<mode>, and read anywhere on the command line. Wins over SOLIUM_QML, SOLIUM_QML_GPU and the configuration’s qml.renderer.

Read at cli.rs:85, renderer.rs:130.

Running a session

SOLIUM_LUA_INIT=<path>

Load this configuration instead of ~/.config/solium/init.lua or the shipped init.lua.

Read at script.rs:952.

SOLIUM_TERMINAL=<command line>

The terminal super+return opens, split on spaces. Unset, the shipped init.lua opens the first terminal it finds installed.

Read at init.lua:123.

SOLIUM_PANE=<name or path>

The frame style for this run, over the configuration’s pane.

Read at check.rs:384, decoration.rs:2074.

SOLIUM_DECORATION=<name or path>

The old spelling of SOLIUM_PANE. SOLIUM_PANE wins when both are set.

Read at check.rs:384, decoration.rs:2075.

SOLIUM_QML_TITLEBAR=<path>

One QML file to frame every window with, over SOLIUM_PANE and the configuration. The oldest spelling of the knob.

Read at check.rs:384, decoration.rs:1886, decoration.rs:2130.

SOLIUM_LOADING=<name or path>

The scene a window shows before its application arrives, over the configuration’s loading.scene.

Read at check.rs:387, pane.rs:1472.

SOLIUM_QML_CURSOR=<path or name>

The pointer’s scene for one run, over the configuration’s cursor.scene: drawn for every shape, ahead of any XCursor theme, and found as cursor.scene is. The shipped cursor.qml is still what is drawn with no scene and no theme.

Read at theme.rs:141.

SOLIUM_QML_PATH=<paths>

The whole QML import path, colon separated. Replaces the search rather than adding to it: the user’s ~/.config/solium/qml and the shipped QML are no longer looked in.

Read at qml.rs:903.

SOLIUM_QML=<mode>

Which scene graph QML renders on: auto, gpu or software. Below --qml, above SOLIUM_QML_GPU and qml.renderer.

Read at dev.rs:246.

SOLIUM_QML_GPU

The older spelling of SOLIUM_QML=gpu. Set to anything, it means gpu.

Read at dev.rs:257.

SOLIUM_FORM_FACTOR=<form factor>

desktop (the default), laptop, tablet or phone. Chooses the input profile: click or touch to focus, whether focus follows the pointer, natural scrolling and the drag modifier. An unknown value is logged and desktop used.

Read at profile.rs:130.

SOLIUM_DRAG_MODIFIER=<modifier>

logo (also super or meta) or alt: the key held to drag a window from anywhere in it. Over the form factor’s default.

Read at profile.rs:144.

SOLIUM_DEBUG_MODE

Set to anything, the same as --debug-mode.

Read at dev.rs:386.

SOLIUM_NO_DMABUF

Set to anything, clients are not offered linux-dmabuf and fall back to shared memory.

Read at tty.rs:1283, winit.rs:231.

SOLIUM_SESSION_BUS=<address>

The D-Bus address to tell about the session, and to own org.freedesktop.ScreenSaver on (idle.dbus_inhibit), instead of the session bus. Nested, or solium --tty without --session, this is the only way either happens. Blank counts as unset. For checking the calls against a private bus: dbus-run-session -- sh -c 'SOLIUM_SESSION_BUS=$DBUS_SESSION_BUS_ADDRESS solium'.

Read at dev.rs:183.

Hosting a shell

A shell is loaded as configuration, with shell = { scene = ... }; see Where Solium ends and Lilium begins. These change that for one run, and dev/run-shell.sh sets both.

SOLIUM_SHELL_SCENE=<file.qml>

The QML file to host as the shell, over the configuration’s shell.scene. Empty counts as unset.

Read at shell.lua:26.

SOLIUM_SHELL_WATCH=<directory>

Where to look for changed .qml files, down to four directories below it, before rebuilding a hosted scene, instead of the scene’s own directory. For a shell whose files live in a tree of their own.

Read at surface.rs:557.

Development and testing

These exist so the compositor can be driven and photographed without a hand on the keyboard. dev/README.md says how they are used. The ones marked nested only are read by the nested backend alone: on the hardware, with --tty, they do nothing.

SOLIUM_CAPTURE=<path>

Nested only. Write a rendered frame to <path> as a binary PPM.

Read at dev.rs:14.

SOLIUM_CAPTURE_AT=<ms>

Nested only. Capture at this many milliseconds after startup, instead of once a window has settled.

Read at dev.rs:86.

SOLIUM_CAPTURE_FRAMES=<n>

Nested only. Capture a burst of n frames rather than one, written beside the path SOLIUM_CAPTURE names with a number added to its stem: /tmp/frame.ppm gives /tmp/frame-000.ppm, /tmp/frame-001.ppm, ….

Read at dev.rs:23.

SOLIUM_CAPTURE_INTERVAL=<ms>

Nested only. The time between the frames of a burst. 16 by default.

Read at dev.rs:78.

SOLIUM_TRIGGER_AT=<ms>:<combo>,...

Nested only. Run what these key combinations are bound to, at these moments: 5200:super+space,6200:super+space. The binding is looked up by the name written here, so this skips what a real key goes through first: the Ctrl+Alt chords, the lock screen and the key-cap lookup that keeps bindings working under another keyboard layout.

Read at dev.rs:98.

SOLIUM_KEY_AT=<ms>:<key>,...

Nested only. Press a key and let it go at these moments, through the real input path: 1500:caps_lock,3000:shift+alt_l. A key is keysym names joined by +, pressed in the order written and let go in reverse; shift, ctrl, alt and super are the left-hand keys. Each is found in the live keymap, in any of its layouts, so Caps Lock locks Caps, an xkb layout-switch option switches the layout, and a combination a binding claims runs it, exactly as from the keyboard.

Read at dev.rs:112.

SOLIUM_CLICK_AT=<ms>:<x>,<y>;...

Nested only. Hand a press at these moments and places, separated by semicolons, to the mode that is holding the pointer, such as overview. It goes to the scripts, not through the input path: for a real press, use SOLIUM_DRAG_AT with the same point at both ends.

Read at dev.rs:121.

SOLIUM_DRAG_AT=<ms>:<x1>,<y1>><x2>,<y2>;...

Nested only. Drag the pointer from one place to another, through the real input path, the real grab and the real layout scripts.

Read at dev.rs:141.

SOLIUM_LOADING_AT=<ms>:<program>,...

Nested only. Open a window for an application that will never arrive, to exercise the loading window.

Read at dev.rs:168.

SOLIUM_OUTPUTS=<n>

Nested only. Give the nested backend n monitors, from 1 to 4, side by side in its one window.

Read at dev.rs:47.

SOLIUM_OUTPUTS_AT=<ms>:<n>,...

Nested only. Change the number of nested monitors at these moments: hotplug without a cable.

Read at dev.rs:68.

SOLIUM_MEMDIAG

Set to anything, report what the compositor is holding, once a second.

Read at dev.rs:266.

SOLIUM_PACING

Set to anything, say where a frame’s time went on the frames that took longer than the monitor’s frame interval, and say it too when a monitor flipped late, which is counted apart as late: the vblanks a monitor should have flipped at and did not, summed over the monitors, from the kernel’s flips (always 0 nested, where there are none). On a monitor with vrr on, whose vblank waits for its frame, a slow frame drawn at a flip delays the next vblank instead of missing it and is rarely counted, while one started from idle is still judged against the monitor’s fastest rate, so vrr’s late does not compare with a fixed rate’s. A line carries the slowest pass since the last line: each phase on the CPU, the three scenes Qt spent the most of it on, costliest first (qml_top, as rounded/Frame=410: each scene’s folder and file, @ the monitor it is hosted on if it is, and its microseconds), how many windows were captured (captures), and the GPU’s time for the pass (gpu, gpu_us, and gpu_prep_us for the captures), which is read a few passes later, so a line waits up to eight passes for it and goes without it (gpu="unread") after that, or sooner, still gpu="unread", if nothing is being drawn and it has still not come (always nested, where the time is read only when a pass draws); gpu reads unsupported on a GPU that cannot time itself and disjoint when its clock jumped. unread is not late, which counts flips. Nested, only the captures and, with several monitors, each monitor are timed on the GPU, not the window’s own draw. Each line also carries the GPU’s clocks as that pass ended: where they come from (clocks: nvml, i915 or none), gpu_mhz, mem_mhz (0 on i915) and NVIDIA’s pstate (-1 where unknown), sampled four times a second by a thread of their own, and none until that thread has found them, which NVML can take a second or more to do; the log says what it found when it has. Misses and late flips are counted whether or not this is set, and every session ends with one pacing line of totals.

Read at dev.rs:285.

SOLIUM_FENCE_WAIT=<on|off>

off (or 0, no, skip) drops a window capture’s fence instead of waiting for it on the CPU: the GPU orders the read after the draw on the one context. on, or unset, waits, which is the default until #59 lands. Each session’s log says which it ran with.

Read at dev.rs:340.

SOLIUM_RECAPTURE=always

Draw every window capture (a warp’s, a rounded client’s) on every pass, as before captures were kept until what they show changes. For measuring the difference on one build.

Read at dev.rs:373.

SOLIUM_PACING_CLOCKS=off

With SOLIUM_PACING, do not start the thread that samples the GPU’s clocks (NVML, or i915’s sysfs). For checking that sampling them does not itself keep the GPU out of its low-power states.

Read at clocks.rs:77.

SOLIUM_TRACE=<path>

Write one JSON line per pass (drew 0 for one in which no monitor was ready to draw), and one per page flip, to <path>, and turn SOLIUM_PACING on. Buffered and written once a second; put it on $XDG_RUNTIME_DIR. Not passed on to the programs Solium starts, so a Solium started inside a traced one does not write over its file. dev/pacing-summary.py reads it; the fields are in dev/README.md, under Measuring frame pacing on a TTY.

Read at dev.rs:296, launch.rs:45.

SOLIUM_RESIZE_TRACE=<file>

Append a per-frame trace of every resize drag to <file>.

Read at resizing.rs:1001.

Packaging

SOLIUM_DATADIR=<directory>

Read when Solium is built, not when it runs: the first place the binary looks for its qml and lua. After it come the tree the binary was built in, then ../share/solium beside the binary, then /usr/share/solium; the first that holds both is used.

Read at assets.rs:76.

Also read

Not Solium’s own, but they change what it does.

RUST_LOG=<filter>

Log levels, in tracing filter syntax. info by default.

WAYLAND_DISPLAY

With this or DISPLAY set and no backend flag given, Solium runs nested, as a window.

Read at main.rs:221, session.rs:281, open.rs:395.

DISPLAY

See WAYLAND_DISPLAY.

Read at main.rs:222, session.rs:284, open.rs:410, open.rs:411.

XDG_CONFIG_HOME

Where solium/init.lua, solium/user.lua and solium/qml are looked for; ~/.config when unset.

Read at folder.rs:66, folder.rs:395, icon.rs:86, qml.rs:944, script.rs:842, script.rs:956.

XDG_STATE_HOME

Where the session log goes; ~/.local/state when unset.

Read at main.rs:78.

XCURSOR_THEME

The pointer theme when the configuration’s cursor.theme is not set.

Read at theme.rs:139.

XCURSOR_SIZE

The pointer size when the configuration’s cursor.size is not set.

Read at theme.rs:140, theme.rs:213.

XKB_CONFIG_ROOT

Where xkb’s data is; /usr/share/X11/xkb when unset. Solium reads rules/evdev.xml there for each layout’s short name, sol.keyboard().layout_short. A layout the rules do not name, or every layout when the file is not there, gets the first two letters of its name, in capitals.

Contributing to Solium

Thank you for wanting to help. This file says how work gets into Solium: where it goes, how to build and check it, how to write the commits, and the design rules a change is judged against.

Report security problems privately, not as issues: see SECURITY.md. Everyone taking part is expected to follow the code of conduct.

Branches

  • stage is the trunk. Every change is a pull request against it. There is no main.
  • release is what releases are cut from. Merging stage into it runs .github/workflows/release.yml, which verifies the tree, bumps the version and drafts a GitHub release. Only the maintainer merges into release.

Making a change

  1. Find or open an issue first for anything bigger than a typo, so the approach can be agreed before the work is done. The daily-drive label is the list of what matters most right now.
  2. Branch from stage, named for the issue: fix-123-short-name.
  3. Keep it to one concern. A pull request that fixes a bug and tidies the module around it is two pull requests. Small and explained is easier to review than large and complete.
  4. Run the gate (below) before opening the pull request, and again after rebasing on a stage that moved.
  5. Open the pull request against stage. The template asks for the checks and carries the contributor agreement sentence; leave that sentence in. Say what changed and why, and link the issue.
  6. Review is a conversation. Push follow-up commits rather than force-pushing over what was reviewed, so the reviewer can see what changed. The maintainer merges; please do not merge your own pull request.

Building

The README’s Building section lists the packages for each distribution. In short: Rust 1.88 or newer (edition 2024), a C++17 compiler, Qt 6.5 or newer (Quick and Qml) with its moc, and the Wayland, libinput, libudev, libseat, xkbcommon, GBM, EGL and libdrm development files. QT_MOC names moc when the build cannot find it; dev/README.md says where it looks.

cargo build
./target/debug/solium          # nested, as a window inside your session

Test nested. Never test against the session you are working in: an empty WAYLAND_DISPLAY does not mean “no display”, it means the default one, which is your real desktop. dev/README.md has the knobs for driving and photographing the compositor without a hand on the keyboard, and the checks that need a running session.

The gate

dev/gate.sh

It checks the formatting (cargo fmt --check; cargo fmt --all fixes it), then runs clippy with every warning denied, the tests, a build, then solium --check (the Lua configuration loads) and dev/wirecheck (the QML GPU path against your machine’s render node, skipped if there is none). It exits non-zero if anything fails, and a pull request is expected to pass it.

It runs cargo natively. On Fedora without the development packages, it can build in an image of your Fedora release instead (FEDORA_VERSION in dev/Containerfile, 44 by default), which needs only podman and rustup:

podman build -t solium-build:fc44 -f dev/Containerfile dev/
SOLIUM_GATE_IMAGE=localhost/solium-build:fc44 dev/gate.sh

SOLIUM_GATE_JOBS, SOLIUM_GATE_CPUS, SOLIUM_GATE_PODMAN_ARGS and SOLIUM_GATE_NO_GPU limit what it uses; see dev/README.md. On another distribution, install the packages natively: what the image builds links Fedora’s libraries and does not run elsewhere. CI runs the same checks except the GPU one, on Fedora, so a contributor whose distribution ships a Qt older than 6.5, which cannot run Solium at all, can rely on CI for them.

Lints are denied, not warned. unwrap_used, expect_used, panic and todo are denied for the whole workspace, because a compositor crash takes the session with it: there is no supervisor to restart it and nothing for the user to look at but a black screen. Handle the error or propagate it. unsafe_code warns rather than denies; each use says why it is sound, in a comment.

A test counts only when it fails before the change and passes after. A new behaviour comes with a test that shows it, and a bug fix with one that reproduces the bug.

Commits

  • One logical change per commit, and the tree builds and passes the gate at every commit.
  • The subject says what is now true, in the present tense, prefixed with the issue it belongs to: #115: the layouts take each window's own floor. A follow-up from review says so: #115 review: small corrections.
  • The body says why. The diff already says what.
  • Comments describe the code as it is, not how it got there. A comment that stops being true when the code changes is changed with it.

Licence and the contributor agreement

Solium is published under the GNU General Public License, version 3 only. Before a first contribution is merged, each contributor agrees once to the Contributor License Agreement: the pull request template carries the one sentence that does it. You keep the copyright in your work.

If a change includes code from another project, say so in the pull request and name its source and licence, and add it to THIRD_PARTY.md. Behaviour reimplemented from another project, with no code from it, is credited there too.

Design rules

These decide whether a change belongs in Rust, in a script or somewhere else. Read docs/architecture.md as well, particularly “The one idea”.

The test that governs design

Every mode — overview, app switcher, peek, genie, tiling, scrolling — is the same operation: place a window’s texture somewhere other than its real geometry and animate between the two.

If a new mode needs new Rust, the transform layer is missing something. Fix the layer; do not special-case the mode.

Rules learned the hard way

The first five come from the Hyprland fork Solium replaces; the rest from Solium’s own first weeks. Each one cost real time, and the next person to hit one will be one of us.

  1. One authority for any piece of state. Two models of window state produced a deadlock where a decoration was needed to learn the geometry that decided whether to create a decoration.
  2. Geometry the compositor animates against must arrive with the frame that shows it. Never a side channel: that is a mirror, and mirrors drift.
  3. Commands are not state. focus_window is a verb; the focus change comes back through the event stream.
  4. A protocol carries one concern. A name that is hard to choose means the interface does too much.
  5. In-process for anything window-coupled. An out-of-process shell painting decorations was measured at ~15 fps and 39% CPU; the boundary was the ceiling. So window decorations are always the compositor’s own. A bar or a dock may run inside the compositor as a hosted shell, in the same QML engine and theme as the frames, or as an ordinary layer-shell client; see docs/shell-boundary.md.

And from Solium’s first weeks:

  • Find out what a dependency’s warning means before believing it. Smithay logs Unable to become drm master, assuming unprivileged mode on runs that work. Its own comment three lines above says why: on a modern kernel the permission is granted implicitly when no other process is master, and it skips the error deliberately. Read as a failure, it once became a P1 issue, a rewritten document and a confident claim that the compositor had never drawn a frame on hardware, against the direct report of someone who had just used it. One look at the source undid all of it. When the evidence and the person who was there disagree, check the evidence.
  • Run a test more than once. Three bugs in one week were found only by repetition. The clipboard flush failed about one run in three: the first run passed, the second passed, the third failed. A single green run is not evidence for anything involving two processes and a protocol.
  • Nested and hardware are different compositors. The pointer was once invisible on the hardware and nothing noticed, because a nested session sits inside a host that draws its own cursor. The two backends also had different drawing policies — one drew every loop iteration, the other on damage — so every animation was developed against the one where a missing damage signal is invisible. They gate on the same test now; keep it that way. The answer is to make the nested backend able to be the hardware in the way that matters, not to test less: SOLIUM_OUTPUTS=n gives it that many monitors, and found a real bug within minutes of existing — the resize handler resized only the first output. On a TTY that is an hour and a session; nested it is a screenshot.
  • Take a backtrace before theorising. An hour once went into deciding why the nested backend hung. eu-stack -p $(pgrep -x solium) answered it in a minute: WlEglSurface::swap_buffers.
  • “It broke when I changed X” is not evidence that X broke it. A nested hang was once the host machine going to sleep, and the change blamed for it, twice, was innocent both times.
  • Measurement scripts lie more often than the compositor. Three false alarms came from screenshot analysis: a “background” pixel that was the cursor, a sample row that landed in a tiling gap, and a synthetic drag that delivered twelve motions in one microsecond, so every client coalesced them into one. Look at the picture.
  • Run the examples in the documentation. The mode guide’s worked example once did not run: require("modes") failed, because the Lua search path was built from the chosen configuration’s own directory, so writing your own init.lua lost every shipped module. That is the central configurability promise, and it had been documented as working since it was written.
  • A compositor cannot test its own protocol support from the inside. “The global is advertised” is a different claim from “a client that uses it gets the right answers”. Asked to anchor a bar, wl-probe found that layer surfaces had never been sent an initial configure: every bar and dock had been invisible for as long as the compositor had claimed to support them, and no shipped check used the protocol. A protocol added gets a check from a client’s side, in wl-probe or as a real client in the test suite: mod real_client in crates/solium/src/state/tests.rs, and crates/solium/src/text_input/tests.rs, which plays zwp_text_input_v3 against a real client on a socketpair.

Naming

  • Wayland protocols carry the solium_ prefix, because interface names are a global namespace. One concern per protocol: solium_frame_v1, solium_window_v1.
  • Rust is named for what it does, never branded. A struct that transforms a window’s presentation is WindowPresentation, not SoliumWindowThing. If a name only makes sense to someone who knows the project’s history, it is wrong.

Testing

  • Test evidence is captured independently of the thing under test. A scene’s or a shell’s own screenshot of itself shows only what it believes it drew, and a host desktop’s screenshot of the nested window shows nothing about what Solium composited. Read back the composited frame instead (SOLIUM_CAPTURE, in dev/README.md). The pictures in the documentation are captured the same way, by the compositor reading back its own framebuffer, because what they show is what it draws.
  • A check that can only be run by hand is written down in dev/README.md, with what it asserts and why no automated test reaches it.
  • A feature written as configuration is tested by a scenario, not by Rust that names it. Lua policy and QML drawing from what the compositor publishes have no Rust of their own, and their tests need none either. A scenario is a Lua file in crates/solium/tests/scenarios/ that returns { qt, user, init, steps }, and cargo test plays it (scenario::tests::every_scenario_with_a_client_passes, every_scenario_on_the_qt_thread_passes). The steps it can take, and what expect sees, are listed at the top of crates/solium/src/scenario.rs; the keyboard-*.lua scenarios are the examples, and the pane-*.lua ones read what a shipped pane style draws, focused or not and under the pointer.

History worth knowing

Solium began as a Hyprland fork. That work is archived, and its post-mortem — most usefully, why an out-of-process QML shell was the wrong architecture — is in the lilium-de repository of the Lilium-Linux organisation. Read it before proposing a helper process.

The documentation site

The docs are also a website, https://lilium-linux.github.io/solium/, built with mdBook and published from stage by .github/workflows/docs.yml. Every pull request builds it as well, so a broken link inside it fails CI.

dev/docs.sh             # build it into target/book, and check its links
dev/docs.sh --serve     # and serve it on http://localhost:3000
SOLIUM_DOCS_IMAGE=localhost/solium-build:fc44 dev/docs.sh   # in the container
  • A doc in docs/ is a page as it stands. docs/SUMMARY.md is the table of contents; a new doc goes there too.
  • The reference pages are generated, and never committed. The configuration comes from crates/solium/lua/config.lua, the key bindings from solium --check, the Lua API from crates/solium/lua/meta/sol.lua and the flags and environment from crates/solium/environment.txt. Change the source, not the page.
  • A new sol.* name needs its entry in sol.lua, and a new SOLIUM_* variable or flag its line in environment.txt. The tests sol_lua_documents_exactly_the_api_the_compositor_registers and the_environment_reference_lists_exactly_what_the_code_reads fail until they have one.
  • Write links as paths in the repository, the way GitHub reads them. dev/docs/links.py points each one at its page in the book, or at the file on GitHub.

Code of conduct

Solium adopts the Contributor Covenant, version 2.1 as its code of conduct. It applies in every project space — issues, pull requests, discussions, reviews — and when someone represents the project elsewhere.

The full text is at that address and is not copied here. In short: be respectful, assume good faith, accept criticism of your work gracefully, and do not harass anyone.

Reporting

If someone’s behaviour breaks the code of conduct, tell the maintainer, @IlyaKotomin on GitHub. For anything you do not want to say in public, open an issue asking for a private contact, without details, and the maintainer will reply with one. Reports are handled confidentially, and the enforcement guidelines in the Covenant are the ones followed.

Solium Individual Contributor License Agreement

Thank you for contributing to Solium. This agreement is between you and the Solium Maintainer (currently Illia Kotomin, together with any successor or assignee of the Maintainer’s rights in Solium). It sets out the terms under which you contribute. It is a license, not a transfer: you keep the copyright in your contribution and may use it however you like elsewhere.

You accept this agreement by opening a pull request that contains the following sentence, or by signing through the automated check on the pull request where one is enabled:

I have read the Solium CLA (CLA.md) and I agree to it for this and all my future contributions to Solium.

1. Definitions

  • “You” means the person who submits the Contribution. If you submit it on behalf of an employer or another organisation, “You” means that entity too, and you confirm that you are authorised to agree on its behalf.
  • “Contribution” means any original work of authorship, including changes to existing work, that You intentionally submit to the Maintainer for inclusion in Solium. That includes code, documentation, artwork, configuration and tests, submitted by any means, including a pull request, an issue, a patch, a comment or a message.
  • “Solium” means the software project published at https://github.com/Lilium-Linux/solium, together with any work the Maintainer derives from it.

You grant the Maintainer, and recipients of software distributed by the Maintainer, a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense and distribute Your Contributions and such derivative works, under any license terms the Maintainer chooses, in addition to the license Solium is published under at the time.

The Maintainer may transfer or assign the rights granted in this agreement.

3. Patent license

You grant the Maintainer, and recipients of software distributed by the Maintainer, a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import and otherwise transfer Your Contributions. The license covers only those patent claims You can license that are necessarily infringed by Your Contribution alone, or by its combination with Solium as it stood when You submitted it.

If anyone starts patent litigation claiming that Your Contribution, or Solium, infringes a patent, the patent licenses that party received under this agreement end on the date that litigation is filed.

4. What you confirm

  • Each Contribution is Your original work, or You have the right to submit it under this agreement. Where a Contribution includes someone else’s work, You say so in the submission and name its source and license. Only work under a license compatible with this agreement’s terms may be included, such as MIT, BSD, Apache-2.0 or Zlib.
  • If an employer or anyone else has rights in what You create, You have their permission to make the Contribution under this agreement, or they have waived those rights.
  • You are not aware of any claim, license or other restriction that conflicts with the rights granted here.
  • You will tell the Maintainer if any of this stops being true.

5. Moral rights

To the extent the law allows, You waive, or agree not to assert, any moral rights in Your Contributions against the Maintainer or recipients, where those rights would prevent the uses permitted in section 2.

6. No obligation, no warranty

The Maintainer does not have to use Your Contribution. Unless required by law or agreed in writing, You provide Your Contributions “as is”, without warranties or conditions of any kind.

7. Everything else

This agreement covers every Contribution You make to Solium, now or in the future, until You tell the Maintainer in writing that it no longer covers future ones. That notice does not affect Contributions already made.

If any part of this agreement is held unenforceable, the rest remains in effect.

Security policy

Solium is a compositor: it holds the keyboard, the screen and the lock screen for a whole session. A bug that lets a client see what it should not, or gets past the lock, matters more here than in most programs, so please report it privately.

Supported versions

VersionSupported
The latest releaseyes
stage, the development trunkyes
Anything olderno — upgrade to the latest release

Fixes land on stage first and reach users in the next release.

Reporting a vulnerability

Use GitHub’s private vulnerability reporting: open the repository’s Security tab and choose Report a vulnerability, or go straight to https://github.com/Lilium-Linux/solium/security/advisories/new. The report is visible only to you and the maintainers.

Please do not open a public issue or pull request for a vulnerability, and do not describe it anywhere public until it is fixed.

A useful report says:

  • what an attacker can do, and from where (an ordinary Wayland client, an X11 client through XWayland, someone at the keyboard of a locked session);
  • the Solium commit or release, and whether it ran nested or on a TTY;
  • how to reproduce it, as a program or a script if there is one;
  • the relevant part of ~/.local/state/solium/session.log.

Examples of what counts: getting input or pixels past the lock screen, the session unlocking without the locking client’s say-so, one client reading another’s input or contents without a protocol that grants it, and anything that runs code as the user from outside the session.

What happens next

  • You get an acknowledgement, and the report is confirmed or questioned, in the advisory’s private thread.
  • The fix is developed privately where that is possible, and merged to stage.
  • The vulnerability is disclosed with the fix: the advisory is published when the fix is merged, not before and not long after, and it credits you unless you ask otherwise.

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.

Developing Solium

How to build Solium, check a change, run it nested, drive and photograph it without a hand on the keyboard, run it on a TTY and install it. CONTRIBUTING.md has how to send a change and the rules it is judged by; this file is the instruments.

Where this file, or a comment in the source, quotes a measurement without saying where it was taken, it was taken in September 2026 on one desktop: an NVIDIA RTX 3070 on the proprietary driver (610.x), Qt 6.11, Fedora 44. None of them has been taken again since, so read them as orders of magnitude.

Settings are not here. docs/ricing.md is how to configure Solium with ~/.config/solium/user.lua, and the configuration reference has every setting. The keys that ship are the Key bindings page, made from what solium --check prints.

Building

Natively, with the development packages the top-level README lists under Building:

cargo build

The build runs Qt’s moc on qml/attached.h, qml/rows.h and qml/keyboard.h, which declare the native Solium QML types. build.rs looks for it in this order: QT_MOC if it is set; the libexecdir Qt6Core’s pkg-config file names; qt6/libexec/moc or qt6/bin/moc beside Qt’s libraries; /usr/lib64/qt6/libexec/moc, /usr/lib/qt6/libexec/moc and /usr/lib/x86_64-linux-gnu/qt6/libexec/moc; and last, moc on PATH. If it cannot run moc, the build stops with could not run moc at … and set QT_MOC to its path, or install Qt 6's development tools. Setting or changing QT_MOC runs the build script again. On Fedora, moc comes with qt6-qtbase-devel, which the build image already has. dev/wirecheck/build.rs runs moc on the same headers from a list of its own, so a new header that declares a Q_OBJECT type goes into MOC_HEADERS in both build scripts.

Or, on a Fedora host without the development packages, in the build image, which needs only podman and a rustup install. The image has the C toolchain and the system libraries; Rust comes from your own CARGO_HOME and RUSTUP_HOME, mounted at the same paths. Build the image for the Fedora release you run (--build-arg FEDORA_VERSION=<n>, 44 by default), for the reasons below; on another distribution, install the packages natively:

podman build -t solium-build:fc44 -f dev/Containerfile dev/
podman run --rm --userns=keep-id --security-opt label=disable \
    -v "$PWD:$PWD" -v "$HOME/.cargo:$HOME/.cargo" -v "$HOME/.rustup:$HOME/.rustup" \
    -e CARGO_HOME="$HOME/.cargo" -e RUSTUP_HOME="$HOME/.rustup" \
    -e PATH="$HOME/.cargo/bin:/usr/bin:/bin" \
    -w "$PWD" localhost/solium-build:fc44 cargo build

The build image matches the host’s distribution on purpose. Two things go wrong otherwise, and both were hit:

  • A vendored C dependency (Lua) is compiled by whatever toolchain builds the project. A container with a newer C library produces a binary the host cannot start at all — GLIBC_2.44 not found.

  • Working around that by running the compositor inside the container costs hardware acceleration. The container has Mesa but no GPU driver, so everything falls back to llvmpipe. Measured on 2026-09-05:

    RendererFrame rate
    In the containerllvmpipe55 fps
    On the hostNVIDIA RTX 3070260 fps

    On a 260 Hz display, 55 fps puts a dragged window four frames behind the cursor, which is exactly what it looks like.

Never glob target/debug/build/solium-*/out/. The Qt host — host.cpp, attached.cpp, rows.cpp and keyboard.cpp, and the moc output of their headers — is compiled into libsolium_qml_host.a under that path, and there is more than one such directory: cargo makes a separate one per unit metadata, so cargo build -p solium and cargo build (or cargo test) each own one. A test harness or a script that links “the” archive by glob picks whichever the shell sorts first, which is not the newest, and there is no error — the link succeeds and you measure a build from an hour ago. It cost a full round of debugging a fix that was already in the tree.

This is not something one commit introduced and another can remove; it is how cargo lays the directory out. Either pin the newest,

ls -td target/debug/build/solium-*/out | head -1

or compile the host from source in the harness’s own build.rs: host.cpp together with attached.cpp, rows.cpp and keyboard.cpp from crates/solium/qml/, with moc run on attached.h, rows.h and keyboard.h, as dev/wirecheck/build.rs does. That is the only way to be certain that what runs is what is checked out.

The gate

dev/gate.sh checks the formatting with cargo fmt --check, then runs clippy with warnings denied, the tests and a build, then two checks on the built binaries that nothing else reaches: solium --check, which loads the Lua configuration and builds the scenes it declares, and dev/wirecheck, which drives the QML GPU path against the machine’s own render node. Run cargo fmt --all first if the formatting step fails. It runs cargo natively unless told otherwise:

VariableEffect
SOLIUM_GATE_IMAGE=<image>Build in this podman image, e.g. localhost/solium-build:fc44. If it does not exist, the gate prints the podman build line that makes it. Only the checkout, CARGO_HOME and RUSTUP_HOME are mounted. solium --check runs in the image too; dev/wirecheck runs on the host, on its render node, and is skipped with a message if the host cannot load what the image built.
SOLIUM_GATE_PODMAN_ARGS=<args>Extra podman run arguments, split on spaces — e.g. "--memory=6g --memory-swap=6g" to cap a build that would otherwise use all the memory there is.
SOLIUM_GATE_JOBS=<n>cargo -j<n>. Unset, cargo uses every CPU.
SOLIUM_GATE_CPUS=<list>Pin the build to these CPUs, as a taskset list such as 14,15.
SOLIUM_GATE_NO_GPU=1Build the GPU check but do not run it. It is skipped anyway on a machine with no render node.

For example, capped and in the container:

SOLIUM_GATE_IMAGE=localhost/solium-build:fc44 \
SOLIUM_GATE_PODMAN_ARGS="--memory=6g --memory-swap=6g" SOLIUM_GATE_JOBS=2 \
    dev/gate.sh

The cargo steps run under nice -n 19 and, where it exists, ionice -c 3, so a gate in the background leaves the machine usable. solium --check and dev/wirecheck are short and run at normal priority.

The tests include the scenarios in crates/solium/tests/scenarios/, played by scenario::tests::every_scenario_with_a_client_passes and every_scenario_on_the_qt_thread_passes. Run natively on a machine whose $XDG_CONFIG_HOME/solium (~/.config/solium) holds any .lua file, the client scenarios (every_scenario_with_a_client_passes) and the script tests built on script::tests::shipped_init_with_user pass without running, because that directory comes first on package.path; the scenario test prints skipped: ~/.config/solium holds Lua of its own, which a scenario would load. Pointing XDG_CONFIG_HOME at an empty directory runs them. With SOLIUM_GATE_IMAGE the container’s HOME is /tmp, so they always run.

One test is known to fail now and then under the gate’s load (#176): qml::wake::tests::a_clock_scene_repaints_once_a_second_with_no_other_damage counts exactly three frames in three and a half seconds, and a one-second Timer that drifts past the end of that window gives two. If it is the only failure, run it on its own (cargo test -p solium a_clock_scene_repaints) before suspecting the change.

CI runs the formatting check, clippy, the build, the tests and solium --check, and not dev/wirecheck, which needs a GPU. A source tarball made with git archive leaves dev/wirecheck out, along with the other hardware harnesses and probes .gitattributes names, and the gate then skips that step and says so.

Running it nested

./target/debug/solium          # a window inside your session
dev/run-nested.sh              # the same, logging to a file and printing the socket

Nested, Solium runs as a window inside the session you are using. That is how almost all of it is developed and checked. dev/run-nested.sh runs it on the host, on the host’s own GPU driver, whether it was built natively or in the build image — the image only builds it, which is why it matches the host’s distribution. It refuses to start without a WAYLAND_DISPLAY, because an empty one means the default socket, which is your real session. host-window-rule.md keeps the nested window from taking focus or landing on the wrong monitor.

Three things are different nested, and they matter for what a nested run proves:

  • QML renders in software: the nested backend hands the compositor no GBM device, so there is no GPU path to take (see QML on the GPU).
  • The host draws a pointer over Solium’s own, so a missing pointer does not show (dev/cursor-check.sh).
  • The knobs below that drive and photograph a session are read by the nested backend only.

Running programs inside Solium

Super+Return starts a terminal: SOLIUM_TERMINAL names one, or else the shipped init.lua takes the first that is installed. A binding in user.lua starts anything else (bindings = { ["super+b"] = "firefox" }; see docs/ricing.md), and so does sol.spawn from a script:

sol.bind("super+e", function() sol.spawn("foot", "-e", "htop") end)

Programs start as clients of this compositor — sol.spawn overrides WAYLAND_DISPLAY in the child, or it would inherit the host’s and open its window next to the nested compositor rather than inside it.

Apart from the session’s own variables (WAYLAND_DISPLAY; DISPLAY for Solium’s Xwayland, or removed; XDG_CURRENT_DESKTOP; XDG_SESSION_TYPE when unset; XDG_ACTIVATION_TOKEN and DESKTOP_STARTUP_ID), a program gets the environment Solium itself was started with (#175). That environment is noted before Qt or EGL has changed it, so nothing they set inside the compositor leaks into a child, and the program holds no descriptor beyond stdio. The compositor’s own Qt drops QT_IM_MODULE and QT_IM_MODULES, but a program Solium starts still gets yours.

Solium runs on the host, so sol.spawn can start anything installed there — with one family of exceptions:

Do not test with KDE’s own applications. They are launched through DBus activation, so the process that actually opens the window inherits the session’s environment and connects to the session’s compositor. The spawn succeeds, the program keeps running, its window appears on the host desktop, and nothing is logged anywhere — the most confusing failure available.

dev/foot is the way round it: a terminal from the build image, connected to whichever compositor invoked it.

SOLIUM_TERMINAL=$PWD/dev/foot dev/run-nested.sh

Anything that is not DBus-activated can be pointed at the socket directly — the launcher prints its name:

WAYLAND_DISPLAY=wayland-1 <program>

To run something else from the build image the way dev/foot runs foot, add its package to the dnf install line in dev/Containerfile and rebuild the image — a --rm container throws anything installed into it by hand away with itself.

The knobs

The ones used most. Every flag and every environment variable Solium reads, with where in the code each is read, is on the Flags and environment page, made from crates/solium/environment.txt; a test fails when the code reads one that file does not list.

VariableEffectNested only
SOLIUM_CAPTURE=<path>Write one rendered frame to <path> as a binary PPM. See Capturing a frame.yes
SOLIUM_CAPTURE_AT=<ms>Capture at this moment after startup instead of once a window has settled. Naming a moment is what makes capturing an animation possible.yes
SOLIUM_CAPTURE_FRAMES=<n>, SOLIUM_CAPTURE_INTERVAL=<ms>A burst of n frames, <ms> apart (16 by default), written beside the path with a number on its stem: /tmp/tile.ppm gives /tmp/tile-000.ppm, /tmp/tile-001.ppm, … One frame shows a pose; a burst shows whether the motion is smooth.yes
SOLIUM_TRIGGER_AT=<ms>:<combo>,...Run what these combinations are bound to, at these moments: 5200:super+space,6200:super+space. Not a keypress: the binding is looked up by the name written, so it fires while the screen is locked, cannot press Ctrl+Alt+Backspace, and cannot show whether a binding works under another keyboard layout.yes
SOLIUM_KEY_AT=<ms>:<key>,...Press real keys, by keysym name, through the real input path: 1500:caps_lock,3000:shift+alt_l. Caps Lock locks, an xkb layout switch switches, and a bound combination runs its binding. See Keys by name.yes
SOLIUM_DRAG_AT=<ms>:<x1>,<y1>><x2>,<y2>;...Drag the pointer through the real input path: the real grab, hit-testing and layout scripts. A drag from a point to itself is a click.yes
SOLIUM_CLICK_AT=<ms>:<x>,<y>;...Hand a press to the mode holding the pointer, such as overview. It never touches the input path; see Driving it without a keyboard.yes
SOLIUM_OUTPUTS=<n>Give the nested backend n monitors, 1 to 4, side by side in its one window. See Two monitors, without a second monitor.yes
SOLIUM_OUTPUTS_AT=<ms>:<n>,...Change how many nested monitors there are at these moments: hotplug without a cable.yes
SOLIUM_LOADING_AT=<ms>:<program>,...Open a window for an application that never arrives, to exercise the loading window.yes
SOLIUM_LUA_INIT=<path>Load this configuration instead of ~/.config/solium/init.lua or the shipped one.
SOLIUM_TERMINAL=<command line>The terminal super+return opens, split on spaces.
SOLIUM_PANE=<name or path>The frame style for this run.
SOLIUM_FENCE_WAIT=offA window capture drops its fence instead of waiting for it on the CPU. The default waits, and each session’s log says which it ran with.
SOLIUM_RECAPTURE=alwaysDraw every window capture (a warp’s, a rounded client’s) on every pass, as before captures were kept until what they show changes. For an A/B on one build, and the control of dev/pacing-nested.sh’s capture count.
SOLIUM_PACINGSay where a pass’s time went, on passes that overran the tightest monitor’s frame; with it, GPU time, clocks and late flips. Misses are counted without it.
SOLIUM_TRACE=<path>One JSON line per pass and per flip, and SOLIUM_PACING on. See Measuring frame pacing on a TTY.
SOLIUM_QML=<mode>auto, gpu or software; see QML on the GPU.
SOLIUM_SESSION_BUS=<address>The D-Bus bus to tell about the session and to own org.freedesktop.ScreenSaver on, instead of the session bus. A nested run, or solium --tty without --session, tells nobody anything without it. To check the calls against a private bus: dbus-run-session -- sh -c 'SOLIUM_SESSION_BUS=$DBUS_SESSION_BUS_ADDRESS ./target/debug/solium'.
RUST_LOG=<filter>Log levels, info by default. solium::qml carries Qt’s own messages.

On the hardware there is no SOLIUM_CAPTURE. A screenshot there is a wlr-screencopy client such as grim, or wl-probe’s WL_PROBE_SHOT.

Capturing a frame

SOLIUM_CAPTURE=/tmp/frame.ppm dev/run-nested.sh

A compositor cannot be verified by looking at it: a desktop screenshot tool captures the host session, which proves nothing about what Solium composited, and using a shell’s own capture to test that shell is circular. So Solium reads its own framebuffer back.

The capture waits until a window has been mapped for 30 frames, which is half a second at 60 Hz and less on a faster host, because a capture of an empty compositor is exactly the misleading result the mechanism exists to avoid. A captured frame is not presented — reading the framebuffer back invalidates the bind, and the following submit would fail to reallocate its EGL surface.

Under timeout, a nested run ends the way a quit binding ends it, so a capture can be scripted end to end:

SOLIUM_CAPTURE=/tmp/frame.ppm SOLIUM_CAPTURE_AT=3000 timeout -s TERM 5 ./target/debug/solium
magick ppm:/tmp/frame.ppm /tmp/frame.png

Driving it without a keyboard

Because a mode that can only be checked by someone pressing a key is a mode nobody checks twice. Enter overview, click the top-right thumbnail, and photograph the result:

SOLIUM_TRIGGER_AT=5200:super+space SOLIUM_CLICK_AT=6000:1200,250 \
    SOLIUM_CAPTURE=/tmp/frame.ppm SOLIUM_CAPTURE_AT=7000 dev/run-nested.sh

Enter and leave, then compare the frame with one taken at rest. With nothing but Solium on screen the whole frame must be identical, because leaving a mode restores the layout exactly:

SOLIUM_TRIGGER_AT="5200:super+space,6200:super+space" \
    SOLIUM_CAPTURE=/tmp/after.ppm SOLIUM_CAPTURE_AT=7400 dev/run-nested.sh

SOLIUM_DRAG_AT performs a drag through the real input path — the real grab, the real hit-testing, the real layout scripts:

SOLIUM_DRAG_AT="8000:400,25>1200,500"    # at 8s, drag from (400,25) to (1200,500)

Aim the start at a titlebar. A press in a client’s own area does not begin a move grab, so a drag that starts there proves nothing — which it did, the first time this was used. Solium’s own frame starts the move itself; the drag modifier (Super, or Alt under SOLIUM_DRAG_MODIFIER=alt) works anywhere in the window.

It exists because dragging was the one interaction that needed a hand on a mouse, and two bugs shipped there in three commits; the second froze the machine. Both are reproducible from a script now: restoring the deadlock and running the line above freezes the compositor on demand, CPU flat, which is how the fix was confirmed to be a fix rather than a rearrangement.

SOLIUM_CLICK_AT and SOLIUM_DRAG_AT are not two spellings of the same thing, and the difference cost an hour. SOLIUM_CLICK_AT calls trigger_click, which is the script click handler — it never touches the input path. SOLIUM_DRAG_AT goes through synth, input::handle and the real pointer, so it is the one that exercises hit-testing, grabs and anything a surface does with a press. A drag from a point to itself is a click:

SOLIUM_DRAG_AT="6500:1436,472>1436,472"

Testing the Developer Tweaks panel with SOLIUM_CLICK_AT showed nothing happening and looked exactly like the panel being broken, when the panel was fine and the instrument was measuring something else.

SOLIUM_TRIGGER_AT has the same limit on the keyboard side: it runs the handler a combination is bound to, and skips what a real key goes through first. A binding under a Cyrillic layout, which answers to its key cap as well as its keysym, needs a real key, and SOLIUM_KEY_AT (Keys by name, below) presses one. With a second layout in the configuration’s keyboard section, switch to it first (shift+alt_l under grp:alt_shift_toggle), then press the binding’s keys, e.g. SOLIUM_KEY_AT="3000:shift+alt_l,4000:super+q"; wl-probe’s WL_PROBE_KEYBOARD shows which layout the client was told is live.

Keys by name

SOLIUM_KEY_AT is the keyboard’s SOLIUM_DRAG_AT: real key events, through input::handle, the keyboard filter and xkb, so it does what a key on the keyboard does. That is the only way to exercise what xkb does by itself – Caps Lock, a layout switch through an option such as grp:alt_shift_toggle – since none of that is a binding SOLIUM_TRIGGER_AT could run:

SOLIUM_KEY_AT="3000:caps_lock,5000:caps_lock,6000:shift+alt_l" dev/run-nested.sh

A key is keysym names joined by +, case aside, as xkb spells them: caps_lock, num_lock, alt_l, return, a. The keys go down in the order written and up in reverse. shift, ctrl, alt and super are the left-hand keys, and control and logo are taken for ctrl and super. Each name is looked up in the live keymap, in any of its layouts, so a is the same key with Russian live; a name no key of the keymap types means nothing of that combination is pressed, and the log says so. The keyboard is the nested session’s own: the configuration’s keyboard section, or the XKB_DEFAULT_* environment.

Checking an animation frame by frame

SOLIUM_TRIGGER_AT=9000:super+t SOLIUM_CAPTURE=/tmp/tile.ppm \
  SOLIUM_CAPTURE_AT=9000 SOLIUM_CAPTURE_FRAMES=16 SOLIUM_CAPTURE_INTERVAL=20 \
  dev/run-nested.sh

The frames land as /tmp/tile-000.ppm to /tmp/tile-015.ppm. The bounding box of what is drawn, per frame, is the animation’s curve. A good ease-out moves most on the first frame and less on each one after, never jumps, and reaches zero at the end. Measured across the modes on 2026-09-05:

ModePer-frame movement (px)
window opens16, 12, 8, 4, 4, 0
overview enters108, 84, 76, 56, 44, 32, 20, 16, 4, 4, 4, 0
tiling arranges120, 92, 84, 56, 48, 32, 24, 16, 8, 4, 4, 4, 4, 4, 0
scrolling arranges224, 100, 72, 64, 44, 32, 20, 16, 8, 4, 4, 4, 4, 4, 0

The window-open row predates open.motion, which is outBack now: it overshoots, so a correct open grows past its size and settles back rather than shrinking monotonically. A jump mid-sequence means a layout wrote geometry without animating it; a stall means something is being recomputed per frame that should not be.

Checks

ScriptWhat it asserts
dev/gate.shformatting, clippy, tests, build, that the Lua configuration loads, and the QML GPU path against a real driver (see The gate)
dev/app-check.sh <program>a client runs, draws, and provokes no protocol error
dev/cursor-check.shthe pointer is visible over empty desktop
cargo run -p wl-probethe protocols answer, a bar lands on the monitor it named, and a screenshot has the desktop in it the right way up
dev/clipboard-check.shcopy and paste across the X11 boundary, all four ways
dev/present-check.sha pivot is the point the matrix leaves alone, a raised window is drawn in front, clicks follow the rect a window is drawn at without following the z it is drawn above, a genie on a second monitor lands on its target, and a tilted window’s menu is drawn whole and in front of it
dev/fence-check.shskipping a capture’s CPU fence wait (SOLIUM_FENCE_WAIT=off) changes no pixel
dev/pulse-control.sh [env…]the renderer is not frozen: a focused window in the pulse style moves in 150 ms
dev/install-check.sh [--no-build]dev/install.sh installs into a DESTDIR under /tmp: every file (the systemd units and the portal configuration included), the absolute Exec, the printed sudo lines, --check from the installed copy using its own share/solium, refusing while it runs (a session started during the build included), refusing to delete through a link, refusing / and a DESTDIR with a space, saying so when the check fails after the files are in place, keeping a unit or portal configuration of the user’s own through an install and an uninstall, solium-session cleaning up after a stand-in Solium that crashed (and only then, and only once it has gone, and unsetting the variables after one that crashed before starting its target while no other desktop holds graphical-session.target) and refusing a second session while one runs, a reinstall saying when the login screen’s session file is stale, and an uninstall that leaves nothing. --no-check skipping the installed binary’s check. A system prefix (--prefix /usr and /usr/local): everything under the prefix and nothing in XDG_CONFIG_HOME, no config.sha256 and no sudo line, --session-dir refused, and --check passing from the staged /usr/share/solium with a broken user configuration. The Fedora package: dev/rpm/solium.spec’s %files against that install both ways; its License naming every installed .license; each Requires and Recommends naming the package that has the file (the Qt QML modules the shipped QML imports with the Qt version clause, Xwayland, flock, xdg-desktop-portal and the backends lilium-portals.conf names, and foot); and the spec refusing to parse without commit and commitdate. See Installing it and A Fedora package
dev/rpm.sh [--jobs N] [--image IMAGE]the package built from the commit checked out, unpacked without installing, passes solium --check with an empty configuration and takes its QML and Lua from its own usr/share/solium. See A Fedora package

And the tools that measure rather than assert:

ToolWhat it is for
dev/leak.sh <cycles> <windows>open and close windows in settled cycles, to tell a leak from a cache
dev/soak.sh <minutes>churn a session for a long time and sample its memory, threads, descriptors and CPU; see Soaking on a TTY
dev/bench.sh solium|sway <seconds>what a nested compositor costs under a fixed client load, Solium and sway alike
dev/previewthe animation and effects engines in a browser; see Tuning animations without running the compositor
dev/wirecheckthe QML GPU path against a real driver, which the gate runs
dev/qtprobewhether this machine’s Qt can render on the GPU at all, outside Solium

cursor-check.sh exists because the pointer was invisible for the whole life of the project and nothing noticed: nested, the host session draws a cursor over the top, so the only place the failure shows is the hardware.

present-check.sh exists because the wiring between a script and the screen is the one part of a presentation transform that no unit test reaches. z and pivot are read in script.rs, carried through present::Frame, and spent in render::by_depth and warp::mesh_part — all of which are pure functions with tests of their own. What sits between them is two field initialisers in the Command::Present arm in state/commands.rs, and reverting both of those to their defaults passes the entire suite. So this measures pixels instead: it places one window at a known rect, turns it twenty degrees about two different points, and reports where the corner went.

The corner is the whole difficulty, and “look at it” is not a weaker version of this check — it is not a check at all. A rotated window’s top-left is not the top-left of anything in the picture: it is one particular vertex of a quad, and which one cannot be read off the image. Drop the pivot and the frame that asked to turn about its own corner comes back byte for byte identical to the frame that asked to turn about its centre — cmp -l counts zero differing bytes, against 382,961 between the two correct frames. There is no visual signal to miss because there is none to have. So the client prints a marker block at its home position, the marker travels with the corner, and present-check/measure.py names the quad vertex nearest it.

The last two claims are the ones no screenshot can make, and they are two halves of the same rule. Solium::window_under (state/hit_test.rs) walks panes in stacking order and asks whether each one, as it is drawn at that moment (drawn_at), owns the point:

  • z never enters the walk. A window raised over the one covering it is drawn in front and still does not take its clicks. Checked with three windows, the third parked out of the way holding focus — with only two, the window that should take the click is already focused, “it worked” and “nothing happened” produce the same log, and the check rests entirely on the compositor emitting a focus event for a no-change refocus.
  • rect does. A window presented somewhere else takes clicks where it is drawn and not where it lives. This is the likelier regression by far — outer.contains is the obvious-looking simplification, it inverts both clicks, and it passes every other check in here.

The last claim, menu, needs a client with a popup on cue, so its window is wl-probe’s (WL_PROBE_POPUP) rather than kitty’s, and it needs wl-probe built beside solium. The window is tilted four degrees, and its menu, which reaches past the window’s bottom-right corner, must be drawn whole: at least the 160x120 pixels of the untilted menu. A warped window’s menu is a capture of its own drawn in front of the window’s warp; drawn inside the window’s capture, it was cut at the window’s edge and lay under its frame.

cargo build -p wl-probe
SOLIUM_CHECK_DIR=/tmp/present ./dev/present-check.sh   # keep the frames

Kept out of gate.sh deliberately: it needs a host compositor to nest in and a client to open, and a gate that cannot run headless is a gate that gets skipped.

dev/fence-check.sh captures the same two windows with the capture’s fence wait on twice, off once, and tilted a degree more once, and fails unless the first three are byte-identical and the fourth is not. Both windows are rounded and both are tilted, so a warp’s capture runs for each on every frame: a rounded window is no longer captured for its corners, so only a warp still captures. Every run sets SOLIUM_RECAPTURE=always: the windows’ content is fixed, so a kept capture would be drawn once at startup and the fence wait would have nothing to skip on the frames compared. Forced, every capture is drawn, and waited for or not, on every frame. A race that shows once in several hundred frames is wirecheck’s to catch (cases 11c and 11d), not this.

dev/pulse-control.sh runs one focused window in the pulse style nested and fails unless two captures 150 ms apart differ: the known-animating control a nested capture is judged beside. Its arguments are extra environment for the compositor; dev/pulse-control.sh SOLIUM_PANE=none is its own fail-first, since nothing on screen moves then.

clipboard-check.sh runs its X11 half in a container, so the host needs no xclip. Run it more than once. The bug it was written for failed about one time in three, so a single green run proves nothing — which is exactly how it nearly shipped.

wl-probe

wl-probe is a Wayland client, and exists because a compositor cannot test its own protocol support from the inside. “The global is advertised” is a different claim from “a client that uses it gets the right answers”, and the gap between those two has been where every protocol bug this week actually lived. Real applications make fine oracles — Firefox’s WAYLAND_DEBUG log is excellent — right up until nothing installed happens to use the protocol you just added. Firefox never binds wp_presentation, so nothing installed could say whether presentation feedback worked at all.

./target/debug/solium &
WAYLAND_DISPLAY=wayland-1 cargo run -p wl-probe

The socket name is chosen when Solium starts, and wayland-1 is only the usual answer: the log line with socket= says which, and dev/run-nested.sh prints it. wl-probe exits non-zero if anything it asked for went unanswered, so it can be a gate.

It also anchors a bar to the top of every monitor with an exclusive zone and checks that each was configured to that monitor’s width — a layer surface that landed on the wrong screen comes back with the wrong number, and there is no way to see that from inside the compositor. Two knobs:

WL_PROBE_BAR=DP-2    only that monitor. One bar on one screen can tell
                     "each monitor's own work area" from "every monitor's";
                     a bar on all of them cannot.
WL_PROBE_HOLD=10     keep the bars up for ten seconds, so the compositor's
                     own frame can be captured — an exclusive zone is a
                     claim about where everybody *else's* windows go, and
                     no client can see those.

That check found that layer surfaces had never worked at all: they were mapped and never sent an initial configure, and a client may not attach a buffer until it has been configured once. Every bar and every dock was invisible, for as long as layer.rs has claimed that any existing panel works.

WL_PROBE_SHOT=/tmp/shot.ppm writes the capture it took out as a binary PPM, because “some pixels were not zero” and “that is my desktop” are different claims and only one of them can be checked by a program. It checks what it can: that the buffer is the size that was asked for, that it holds more than one colour, and — the one thing no event can tell a client — that it is the right way up, by looking for the top-anchored bar in the top rows. That check earned itself immediately: the first capture came back upside down, which is perfectly legible and reads as a compositor bug rather than a row-order one.

It also reports each monitor’s scale and logical size as a client sees them, which is the only place that can be checked from — and the second thing it caught was itself: it compared a layer surface’s configure against the monitor’s mode and called a correct 2x answer a bar on the wrong monitor. Layer sizes are logical. A probe that is wrong about the protocol is worse than no probe, so its expectations are worth as much scrutiny as the compositor’s behaviour.

With WL_PROBE_HOLD set it also reports where the compositor said the pointer was, in the bar’s own coordinates. Drive the pointer somewhere known and the two numbers should match:

SOLIUM_DRAG_AT="4000:400,20>400,20" ./target/debug/solium &
WL_PROBE_HOLD=7 WAYLAND_DISPLAY=wayland-1 ./target/debug/wl-probe

That is the only way to check it: the compositor works in its own coordinates and subtracts the surface’s corner to get the client’s, and subtracting the wrong corner is invisible from the inside. It was subtracting the wrong corner. Every bar and dock was told the pointer was at 0,0 no matter where it really was — so every button on every panel would have missed, and the one at the top-left corner would have swallowed every click.

WL_PROBE_LOCK=<seconds> locks the session, covers every monitor in a green nothing else draws, holds it, and unlocks. Kept out of the ordinary run on purpose: a check that locks the screen partway through a gate is a check that locks the screen of whoever ran the gate. Two knobs:

WL_PROBE_LOCK_SKIP=DP-2   give every monitor a lock surface except that
                          one. It must go blank, not show the desktop —
                          and with one lock surface per screen that is
                          the only failure the picture can distinguish.
WL_PROBE_LOCK_ABANDON=1   leave without unlocking, as a crashed lock
                          program would. The session must stay locked.

Without a lock client installed there is otherwise no way to exercise the protocol at all: a compositor cannot lock itself, and every question worth asking about a lock screen is a question about what a second process can see. Capture a burst while it runs (SOLIUM_CAPTURE=/tmp/lock.ppm SOLIUM_CAPTURE_FRAMES=<n>) and read the frames: once the desktop has gone, nothing of the session may appear in any of them, before the client’s colour or after it.

The ordinary run also reports the keyboard: the layout names in group order, which one is active, and the repeat rate. That is the only way to check a compositor’s keyboard from outside it — the keymap arrives as a file descriptor and no event describes what is in it. Two knobs:

WL_PROBE_KEYMAP=/tmp/km.xkb   write the keymap out. Forty thousand bytes
                              behind a file descriptor is the compositor's
                              most opaque answer, and "the layouts look
                              right" is a different claim from "here is
                              what it sent".
WL_PROBE_KEYBOARD=10          map a window, take focus, and report every
                              layout change the compositor announces.
                              Pair it with a scripted binding:

    XDG_CONFIG_HOME=/path/to/config \
        SOLIUM_TRIGGER_AT="6000:super+shift+k" ./target/debug/solium &
    WL_PROBE_KEYBOARD=10 WAYLAND_DISPLAY=wayland-1 ./target/debug/wl-probe

The compositor switching its own layout and the client being told are different claims, and only the second one matters: a layout that changed and was not announced is a keyboard that types the wrong letters.

Reading the keymap took three wrong turns, all of which looked right, and they are written down in layouts_in_keymap_text because each would cost the next person the same hour. The short version: the fd’s offset is at the end, so read returns zero bytes and looks exactly like a compositor that sent nothing — clients are told to mmap it, which is why every real toolkit works. The xkb_symbols section name looks like a parseable recipe and is a trap. And libxkbcommon writes name[1]=, not the name[Group1]= that xkbcomp writes and every example online shows.

WL_PROBE_IDLE=1 runs the whole idle sequence against a real window: go idle on a short timeout, take an inhibitor and come back, hold it through more than the timeout and stay awake, drop it and go idle again. The sequence is the check, not any one event in it — a compositor that advertises the inhibitor and ignores it passes the first step, and the third step looks exactly like a compositor whose timer stopped. Two more, both opt-in because of what they do:

WL_PROBE_IDLE_LOCK=1      lock the session with the inhibitor still held.
                          It must go idle anyway: a player left running
                          behind a lock screen would otherwise keep the
                          machine awake all night showing a lock screen.
WL_PROBE_IDLE_HIDDEN=8    hold an inhibitor and wait for something else to
                          hide the window. Pair it with a workspace switch:

    SOLIUM_TRIGGER_AT="6000:super+2" ./target/debug/solium &
    WL_PROBE_IDLE=1 WL_PROBE_IDLE_HIDDEN=8 WAYLAND_DISPLAY=wayland-1 \
        ./target/debug/wl-probe

That last one earned itself on the first run. The compositor was asking whether the window’s slot was on a monitor, and a workspace switch does not move a window’s slot — it slides the window away from it with a presentation transform. So a video on a workspace nobody was looking at went on holding the machine awake, and no amount of reading the inhibitor code would have shown it.

SOLIUM_DRAG_AT fires during a lock too, and that combination is worth keeping: it is what found that a locked screen would still let a drag resize a window underneath it. The window was measured before the lock and after the unlock, which is the only way that bug is visible — while locked, nothing of it is on screen to see.

Four more modes. WL_PROBE_WINDOWS=<seconds> maps two windows, one framed by the compositor and one that draws its own, and holds them up to be looked at; with WL_PROBE_FULLSCREEN=1 the first asks for fullscreen a second in, so the change can be photographed. WL_PROBE_POPUP=<seconds> maps one window and a menu reaching past its bottom-right corner, each in a colour nothing else draws, for dev/present-check.sh’s menu case. WL_PROBE_CONNECT_ONLY=1 connects, counts the globals and leaves, for hunting what a client that never draws costs.

Hotplug, and how to test it without a cable

Monitors arriving and leaving (#43) work, and were checked on the hardware, with real cables, before #43 was closed. What that testing found is fixed, and how the first hardware test failed is below.

On the hardware there are two ways to exercise it, and the second does not involve reaching behind the desk:

  1. Turn a monitor off at its own power switch, or unplug it, with the session running. The log should say monitor gone, the remaining screen should take the windows, and turning it back on should say monitor arrived.
  2. Set enabled = false on a monitor in config.monitors and press super+shift+r. That goes through the same resync_screens – an enabled change is an unplug as far as everything downstream is concerned – so it exercises the whole path from a keyboard.

The second one existing is why the two share a path rather than each having their own: a configuration reload is something people do at a desk many times an evening, and unplugging a monitor is something they do once. The path that gets exercised is the one that works.

Nested, the DRM half cannot run: the nested backend has no connectors, and a virtual one (vkms) needs a kernel module loaded as root. The half that broke on the hardware both times — what happens to a window whose monitor went — runs nested exactly as it does there, because both backends end in the same settle_monitors. SOLIUM_OUTPUTS_AT takes a nested monitor away and gives it back:

SOLIUM_OUTPUTS=2 SOLIUM_OUTPUTS_AT="5000:1,9000:2" dev/run-nested.sh

The part of the DRM half with reasoning in it, tty::gone, which decides which screens went, has unit tests of its own.

The first hardware test failed, and how it failed is worth keeping: the uevent arrived three times, the connectors changed is in the log three times, and nothing else happened. get_connector was being asked with force_probe = false, so the kernel handed back the answer it had cached before the cable moved. The uevent is the kernel saying come and look; a compositor that comes and looks at its own notes learns nothing. There is now a log line for exactly that shape – the connectors changed and the set of screens did not – because from the log it was indistinguishable from the event never arriving.

What to watch for, because these are the ways it goes wrong quietly: a wl_output that stays in wayland-info after the monitor is gone; windows left on a screen nobody can see; a bar that does not come back when the monitor does; and the second monitor failing to light when it is moved from one port to another, which is the case where dropping has to happen before adding because there are fewer CRTCs than connectors.

Two monitors, without a second monitor

SOLIUM_OUTPUTS=2 dev/run-nested.sh

Two monitors side by side in the nested window, each with its own layer map, work area and render pass, each drawn into a texture of its own — which is what having its own scanout buffer means — and each with its own instance of every sol.surface declared on it, the hosted shell’s included (shell.on in config.lua says which monitors), each reading its own Solium.monitor, so SOLIUM_OUTPUTS=2 dev/run-shell.sh <shell-dir> shows a shell on two monitors. SOLIUM_OUTPUTS_AT builds or drops instances as monitors arrive or go, and resizing the nested window, which changes the monitors’ sizes without adding or removing one, gives each surface an instance on every monitor it now covers. Every per-output path runs for each of them; what it cannot simulate is a second pipeline, one refresh rate and one page flip per screen, which is tty.rs’s half of the problem.

It exists for the same reason cursor-check.sh does, and it earned itself immediately. The window-resize handler only ever resized the first output, so the left screen took the whole window’s width and covered the right one, and a pointer over the second monitor was answered with the first — a window opening on the screen you are not looking at. That would otherwise have been found on a TTY, where nothing can be read and each attempt costs a session.

The nested monitors are called winit-1, winit-2 and so on. Give one of them a scale in user.lua and you have a HiDPI screen to look at without owning one:

return {
    monitors = {
        { name = "winit-1" },
        { name = "winit-2", scale = 2, right_of = "winit-1" },
    },
}

Each monitor takes its share of the window’s pixels and its logical size is that share divided by its scale, so you are looking at the pixels that monitor would scan out rather than a picture of them. A 2x monitor’s half of the window holds half as much desktop at twice the detail.

Combine it with the scripted input knobs to place windows on a chosen screen: SOLIUM_DRAG_AT moves the pointer through the real input path, and the active monitor is the one the pointer is on.

SOLIUM_OUTPUTS=2 \
  SOLIUM_DRAG_AT="1000:100,500>400,500;3500:400,500>1200,500" \
  SOLIUM_LOADING_AT="1500:one,2500:two,4000:three,5000:four" \
  SOLIUM_TRIGGER_AT=6000:super+t \
  SOLIUM_CAPTURE=/tmp/tile.ppm SOLIUM_CAPTURE_AT=8000 \
  dev/run-nested.sh

Two windows tiled on each screen, in one capture, with no hands.

Turning screens off

Screens can be turned off without being taken away (#54): by a client over wlr-output-power-management (wlopm, or swayidle driving it), by sol.monitor_power from a binding, and by the idle blank after idle.screens_off_after. Nested there is no display to power off, so a monitor’s share of the window is drawn black once and then not drawn, its client is told off, and the log says it was blanked. All of it can be tried in a window that way. The DRM half — a black frame, then the CRTC cleared, and a modeset back on the next frame — has run on the hardware at dfc95ce: the screens going off after idle.screens_off_after works on an NVIDIA RTX 3070 desktop and on a Microsoft Surface Pro 7 (Intel Ice Lake), both on Fedora 44. All three ways of asking end in Solium::set_power (power.rs).

Tuning animations without running the compositor

dev/preview
xdg-open crates/animation/preview/preview.html

Plain boxes animating through the scenarios the compositor actually has — tiling, scrolling, overview entering and leaving, a window opening and a window closing — on a mock output in Solium’s own coordinates. Curve, duration, playback speed, the arrangement’s own numbers (gap, split ratio, column width) and the spring’s stiffness, damping and throw are all live. dev/preview also writes crates/effects/preview/preview.html, which shows where each vertex of a window goes for a named deformation, such as the genie.

Deliberately plain. The page is a test harness, not a mockup: recreating the compositor’s chrome here would be a second copy of its design, drifting from the real one, and motion reads more clearly without decoration anyway.

The engines are compiled to WebAssembly and called from the page, so the curve tuned in a browser is the code that will move real windows. Verified rather than asserted: the same calls through wasm and through native Rust agree to six decimal places. A reimplementation of the curves in JavaScript would drift the first time either side changed, and the drift would be invisible — the page would still animate plausibly.

That includes the geometry. The tiling and scrolling scenarios are arranged by crates/layout, the same arrangements the compositor’s scripts ask for, also compiled to WebAssembly. What the page still owns is where a window starts and ends in the overview and open and close scenarios.

Curves live in crates/animation, which depends on nothing so it keeps building for the browser. Add one there and it is available to scripts by name (sol.animate{ easing = "spring" }) and to the preview at once.

Running a shell inside Solium

A shell is hosted in the compositor’s own QML engine, as configuration — see docs/shell-boundary.md. To work on one, point dev/run-shell.sh at its checkout:

dev/run-shell.sh <shell-dir>                  # its shell.qml
dev/run-shell.sh <shell-dir> dock/Dock.qml    # one file of it
SOLIUM_SHELL=<shell-dir> dev/run-shell.sh

The checkout is required and only ever read. The scene goes in as SOLIUM_SHELL_SCENE, so your own shell.scene is left alone, and editing anything in the checkout reloads the scene within half a second. SOLIUM_SHELL is read by the script, not by the compositor.

solium --check-qml <file> loads one file without starting a compositor and prints ok or the errors Qt reported — the quick way through a chain of “type X unavailable” errors while writing a shell. It exits with 1 when the file does not load.

QML on the GPU

QML renders on the GPU by default. Qt comes up on its OpenGL scene graph and renders each scene into a dmabuf the compositor allocated through GBM, rather than rasterising it on the CPU into a QImage the compositor then uploads. Software is the fallback. A machine where the probe below fails still gets a desktop; one where the probe passes and the GPU then fails inside the compositor gets one from its next start — see When the compositor’s own start fails.

ModeWhat it does
autoThe default. On the hardware, the GPU pre-flight runs first in a short-lived child process — this binary, as --probe-qml-gpu — and QML renders on the GPU if it passes, in software if it fails or takes longer than qml.probe_timeout (5000 ms, after which the child is killed). Nested, software: winit hands the compositor no GBM device.
gpuThe GPU, with no child probe. Nested this draws no QML at all, for the same reason.
softwareQt’s software rasteriser.

Asked for in this order, and the first that says anything decides:

  1. --qml <mode> or --qml=<mode>, after the backend: solium --tty --qml software
  2. SOLIUM_QML=<mode>
  3. SOLIUM_QML_GPU set to anything, the older spelling of gpu
  4. qml = { renderer = "<mode>" } in the configuration (config.lua or user.lua)
  5. auto

A value that is not a mode warns and means auto, at the level it was written: SOLIUM_QML=sofware does not fall through to the configuration. The configuration can hold this because it is read before the first scene starts Qt. It is read once, though: Qt fixes its scene graph for the life of the process, so a reload does not change it and a restart does.

Why a child process. Qt picks its scene graph inside QGuiApplication and there is no way back. The pre-flight — a render node, the KMS config that keeps Qt off the card, a real allocation, a real QML render, the import and the fence — used to run only inside the compositor, after that choice was made, so a failure left no software path to fall back to. Run in a child, a failure costs the child. Once it passes, the compositor runs the same pre-flight again in its own process, as it always did.

When the compositor’s own start fails. A passing probe is not proof. The compositor’s own pre-flight runs later, inside the compositor and once Qt is committed, and the probe did not share its conditions: the child holds none of the compositor’s seat, card or display. If it fails there, the session cannot fall back: scenes do not draw, Solium’s own QML arrow for the pointer included. If Qt aborts, the compositor goes with it. So under auto the compositor writes $XDG_STATE_HOME/solium/qml-gpu-pending (~/.local/state/solium/) just before committing Qt, and removes it once the pre-flight passes. A failure replaces it with qml-gpu-failed, holding the reason, and a pending file still there at the next start — the compositor stopped inside the start — counts as a failure too. The next start of the same build then renders in software without probing, and says so once, naming the file. Delete the file to let auto try again, or force the GPU with --qml gpu. A rebuild tries again by itself, because each file begins with the build that wrote it: its version, its binary and when that was written. A machine where this happens loses one start, not every login. gpu and software neither read nor write these files.

Which one is running. A line at startup begins QML renderer: and says which, and why. When the compositor’s own GPU start then goes wrong it logs another, and the last one is the one that stands:

INFO solium::qml::renderer: QML renderer: gpu, the probe passed from="qml.renderer" probe_ms=160 said="gpu: Qt rendered QML into a buffer allocated on /dev/dri/renderD128, fenced"
INFO solium::qml: QML on the GPU: Qt rendered into a buffer we allocated node=/dev/dri/renderD128 fenced=true

A fallback is a single warning with the reason and both ways out:

WARN solium::qml::renderer: QML renderer: software, because the GPU probe failed: no DRM render node could be found (exit status 1). `--qml gpu` or SOLIUM_QML=gpu forces the GPU; `--qml software` or SOLIUM_QML=software skips the probe

A compositor start that fails after the probe passed, and the start after it:

ERROR solium::qml: QML renderer: gpu, and it does not work: <reason>. Scenes will not draw in this session. The next start of this build renders QML in software, as recorded in ~/.local/state/solium/qml-gpu-failed
WARN solium::qml::renderer: QML renderer: software, because this build's last GPU start in the compositor did not work: <reason>. Delete ~/.local/state/solium/qml-gpu-failed to let auto try the GPU again, or force it with `--qml gpu` or SOLIUM_QML=gpu

The probe on its own. It needs only a render node, not DRM master, so it is safe to run from inside another desktop session:

$ ./target/debug/solium --probe-qml-gpu; echo $?
gpu: Qt rendered QML into a buffer allocated on /dev/dri/renderD128, fenced
0

Exit 0 and one line on stdout when the GPU path works here; non-zero and one line on stderr saying why when it does not. RUST_LOG=info shows its steps. Measured on 2026-09-29 it took 155–180 ms, once 456 ms on a cold first run, and that is what auto adds to a hardware session’s startup.

QML on the GPU: Qt rendered into a buffer we allocated, and the probe’s fenced, mean a buffer was allocated, imported into Qt’s context as a texture, drawn into by real QML, and fenced with a sync_file the driver exported. fenced=false — the probe’s unfenced — is also a pass: the driver declined to export a fence and the host waited with glFinish instead, which costs a stall and nothing else.

Five things worth knowing about the GPU path on a TTY:

  • Qt must be kept off the card node. Solium writes $XDG_RUNTIME_DIR/solium-eglfs-kms.json naming the render node and "headless", and sets QT_QPA_EGLFS_KMS_CONFIG before Qt starts. Without it eglfs opens /dev/dri/card1, and because Qt starts while the scripts load — before open_gpu — the kernel hands Qt DRM master, logind’s SetMaster then fails, and the only thing said about it is Smithay’s unable to become drm master, which is benign noise every other run. A black screen on a TTY with nothing to read. Both JSON keys are needed: device alone fails the plugin with drmModeGetResources failed (Permission denied), headless alone still opens the card.

  • Qt is told not to install signal handlers, and it matters more than it sounds. eglfs builds a QFbVtHandler, which takes SIGINT, SIGTERM, SIGCONT and SIGTSTP. Those handlers do not exit; each writes a byte to a socketpair, and the _exit(1) happens whenever Qt’s event queue is next drained — here, qml::tick’s processEvents, which render::prepare only reaches when something wants a frame. So a SIGTERM to an idle compositor does nothing at all, and the next redraw turns it into an _exit(1) from inside a render, skipping every Rust destructor: the libseat session, the DRM master release, the VT restore. Whether it happens depends on whether anything asked for a frame afterwards, which is not a property you want in the path that puts your console back.

    QT_QPA_NO_SIGNAL_HANDLER=1 is set beside the QT_QPA_EGLFS_* variables and removes it: measured, the process dies on SIGTERM with the knob on, both idle and while drawing. QT_QPA_ENABLE_TERMINAL_KEYBOARD=1 goes with it — despite the name it tells Qt to leave the console keyboard alone, which it otherwise mutes when stdin is a terminal and un-mutes from a destructor this process never runs.

    Ctrl+Alt+Backspace was never affected either way: it is decided in input/mod.rs from Solium’s own libinput devices, and QT_QPA_EGLFS_DISABLE_INPUT=1 means eglfs creates no input handlers to compete with them.

  • Qt’s diagnostics go through tracing now, and needed to. As of 870aacc host.cpp installs a qInstallMessageHandler that forwards to solium_qml_log_from_qt in qml.rs, so QML binding errors, console.warn and every qWarning in Qt and in the host come out in the compositor’s own log with Qt’s logging category as a field. QT_FORCE_STDERR_LOGGING is no longer needed for any of it.

    What it was before is worth keeping, because it is why four failures in the rice spike were silent. Qt’s default handler picks its destination from whether stderr is a console: stderr when it is, journald when it is not — measured both ways on this Fedora Qt 6.11. So a TTY session, where stderr is the VT the compositor has just covered, printed the whole diagnostic half of the host onto a screen nobody could read and into no log at all, while a 2>&1 | tee run quietly put it in journald where nobody was looking. Neither ever reached session.log.

    RUST_LOG now gates Qt as well, under the solium::qml target like the rest of that module. One consequence: a QML console.log is a QtDebugMsg, so it needs RUST_LOG=debug — and QT_LOGGING_RULES='qml.debug=true', because Qt’s own category filter drops it before the handler is called. console.info and above need neither.

  • A one-frame probe cannot see the bug that matters. Qt’s QOpenGLContext::currentContext() is a thread-local Qt sets in its own makeCurrent. The compositor takes the thread back with a raw eglMakeCurrent, which Qt never sees, so that thread-local goes stale rather than null — and QRhiGles2::ensureContext() reads exactly it, concludes its context is already current, and issues the whole frame against whichever context really is: the compositor’s.

    Nothing fails when this happens. beginFrame, sync, render and endFrame all return, the fence is a real sync_file and signals in under a millisecond, and the dmabuf stays full of zeros. Measured on a 64x64 scene: 16384 of 16384 bytes zero with the compositor’s context current across the render, 0 of 16384 bytes wrong with Qt’s. The first frame after a scene is built works either way, because initialize() left Qt’s context current and nothing has taken it yet — so a probe that builds a scene, renders once and reads the buffer passes while every frame after it draws nothing.

    clear_stale_current_context in host.cpp is the fix: doneCurrent() on whatever Qt believes is current, when EGL says otherwise. restore in qml/paint.rs is the other half, and neither works without the other.

  • A GPU scene’s buffer is not stored the way you would guess. QRhi leaves an OpenGL texture render target in the framebuffer’s own orientation, origin bottom-left, so the scene’s top row lands in the buffer’s last row. A dmabuf is top-down unless it says otherwise and ours does not, so the scene comes out upside down. host.cpp calls QQuickRenderTarget::setMirrorVertically (mirror_for_the_compositor) on every GPU render target for that reason — the one a scene starts with, and the ones solium_qml_scene_resize and solium_qml_scene_rebind build again, which are easy to miss.

    Do not try to correct it on the compositor’s side. Smithay’s y_inverted texture flag negates the texture matrix’s y row without the matching translation, and a Transform::Flipped180 on the render element mirrors within the element’s logical size while its source rectangle is in device pixels — right at scale 1, wrong on every scaled monitor.

Running it on a TTY, as a real session

The compositor picks its backend from the environment: nested when there is a compositor to nest in, on the hardware otherwise. --tty asks for the hardware wherever it is on the line, and a flag Solium does not know starts nothing: it is refused with exit status 2. solium --help lists the flags.

First, from your desktop, check what the hardware offers. This opens the card read-only and takes no DRM master, so it is safe to run inside a running session:

target/debug/solium --probe

It names the seat and the GPU, then lists each connector: for one that is connected, how many modes it offers, the one it would choose, its size, dpi and the scale it would pick, and then every mode, in the form the configuration’s mode takes.

Then, on a free TTY. Ctrl+Alt+F3 (or any free one), log in, and:

cd path/to/solium
./target/debug/solium --tty

Leave SOLIUM_TERMINAL unset, or name kitty or foot: see Terminals below for why not konsole.

No sudo: the session, the GPU and the input devices are all opened through libseat, and a compositor that needs root is one nobody should get used to running.

Getting back. Ctrl+Alt+F1 or F2 returns to your desktop session; Solium keeps running on its own VT until you switch back and stop it. Ctrl+Alt+Backspace stops it outright, unless the screen is locked.

Both of those are Solium’s own doing, and that is not a detail. Once libseat puts the VT into graphics mode the kernel stops acting on Ctrl+Alt+F-keys, so a compositor that does not handle them itself cannot be escaped from the keyboard at all — which is how the first run of this backend ended in a reboot.

Two more things stand between you and that: if libinput reports no input devices within twenty seconds, Solium stops on its own rather than hold a display nobody can talk to; and from another VT, pkill -x solium always ends it. The first SIGTERM stops it cleanly. A Solium that cannot stop, because something inside it has stopped answering, ends itself five seconds later (session.stop_timeout), or at once on a second pkill -x solium.

That second one is worth a qualification, because it is a last-resort escape and you are reading it before taking a VT. It holds because Solium’s handlers never wait on the event loop: the second signal ends the process from the handler itself, and the timeout from a thread of its own (signals::tests::the_same_signal_twice_ends_a_process_that_cannot_stop and a_process_that_cannot_stop_ends_when_its_stop_timeout_has_passed). With QML on the GPU it holds only because Solium sets QT_QPA_NO_SIGNAL_HANDLER before starting Qt — without it eglfs installs its own SIGTERM handler, and the process then neither dies nor cleanly survives. See QML on the GPU. On a build that predates that, or one where QT_QPA_PLATFORM was set from outside, reach for pkill -9 -x solium.

Reading what happened. A session started with --tty as its first argument writes to ~/.local/state/solium/session.log (under $XDG_STATE_HOME when that is set) as well as to the terminal, because the terminal is underneath the compositor and cannot be read while it runs. A bare solium on a VT also takes the hardware, and writes no log file. The log is appended to, so the log of a run that went wrong survives the run that was meant to fix it — and, being under state, it survives a reboot.

Terminals. SOLIUM_TERMINAL picks one; otherwise lua/init.lua takes the first that is actually installed. That fallback exists because naming a terminal that is not installed looks, from the keyboard, exactly like the binding being broken — which is how the first hardware session went.

The order is deliberate: plain Wayland terminals (kitty, alacritty, wezterm, foot) come before konsole, and xterm is last. A KDE app started under Solium sits for around twenty seconds before its window appears — close enough to the D-Bus activation timeout to be worth naming, since KDE apps ask for portal and session services that are not running here and wait for them to time out. vkcube and kitty both map in a second or two, so this is the app waiting, not the compositor failing to map it. konsole stays on the list as a fallback for a machine that has nothing else, never as a preference.

What the hardware has and has not been through. Hotplug has (see Hotplug above). So has dfc95ce, on an NVIDIA RTX 3070 desktop and on a Microsoft Surface Pro 7 (Intel Ice Lake), both on Fedora 44: QML on the GPU and its animations, the screens going off when idle (see Turning screens off), swaylock locking the session, and the Caps and layout pill with no configuration. On the Surface Pro 7 the screen also went off when the lid was closed, though Solium itself has no lid handling. Touch reaches a client’s window (Firefox), but nothing Solium draws reacts to it: frame buttons, a hosted shell, overview and the edges (#181).

Soaking on a TTY, which is the only honest soak

A nested soak measures the nested backend as much as the compositor: Solium is a client of the host there, with its own EGL surface, its own cursor theme and its own client-side libraries, none of which exist on a real session. A leak found nested is a leak somewhere, and saying which needs the other backend.

SOLIUM_SOAK_TTY=1 dev/soak.sh 60       # on the TTY: start the session and soak it
dev/soak.sh 60 --attach wayland-1      # from another VT or over ssh: sample one already running

On a TTY only half the workload runs. The hardware backend reads none of the scripted knobs, SOLIUM_TRIGGER_AT included, so the cycle of key presses dev/soak.sh hands the compositor does nothing there, and neither does a list made with dev/soak.sh --triggers. What churns is the client side: terminals opened and closed, and an X11 client now and then. Nested, the whole cycle runs. Until the hardware backend reads the triggers, a soak of the window lifecycle on a TTY needs the windows opened and closed from outside it.

Measuring frame pacing on a TTY

dev/pacing-tty.sh runs pinned scenes on the hardware and measures every pass of them, with SOLIUM_TRACE writing a line per pass and per flip. S1 is the rounded pane style, four idle terminals and one player at fixed rectangles on the monitor you name, over the shipped wallpaper; S1 tilted is the same with the player held at 6 degrees, so its picture is captured again whenever it draws; and the tilt scene is two tilted terminals, one idle and one ticking. It is what Phase 0 of the shader work is judged by (FX-S1), and the 260 Hz numbers come only from here: nested, there is no page flip and QML is software. The tilts are made by the scenes’ Lua, because the hardware backend reads none of the scripted knobs.

dev/pacing-tty.sh before DP-1     # fourteen runs, about 21 minutes
dev/pacing-tty.sh after DP-1      # nine runs, about 14 minutes
dev/pacing-tty.sh fence DP-1      # one fence pair, about 3 minutes
dev/pacing-tty.sh one DP-1        # one run, to try it

Run it on a free VT, logged in, from the worktree the measured binary was built in, and keep your hands off the keyboard and mouse until it says done. Nothing else should be building or running on the GPU meanwhile; each run’s meta.txt records the load and any cargo or rustc it saw. Each run is capped by a timeout; Ctrl+Alt+Backspace stops the compositor at any moment and the script with it. It writes under ~/.local/state/solium/pacing/<date>-<protocol>/: per run, meta.txt (the build, the player, the kernel, the load, the driver and its clocks before the run), stderr.log, the trace and summary.txt; and results.txt with every summary. The compositor’s own state and configuration directories are the run’s, so your session.log and user.lua are untouched. Without mpv and PACING_CLIP, ffplay plays a test pattern; every run that is compared must use the same player.

PACING_BINARY runs a binary frozen for a measurement. Freeze a release build (cargo build --release) in a worktree of its own and run the script from that worktree: the binary finds its QML and Lua in the tree it was built from, so a worktree that keeps changing would change what is measured, and the dev profile’s missed is not the shipped one’s. after also runs PACING_ANCHOR, the build an earlier run measured, once, so two sittings can be compared through it. dev/pacing-nested.sh <label> runs the same scenes nested (PACING_SCENE=tilt, PACING_TILT=6) and prints the same summary; its numbers are not the TTY’s. Nested, only the captures are GPU-timed: timing the window’s own draw needs a GL call after its swap, which leaves the window’s surface uncurrent, so the next pass could not read its buffer age and would redraw everything.

A pass record carries pass, t_ns (CLOCK_MONOTONIC at its start), total_us, deadline_us, monitor, missed, each phase as <phase>_us, captures, panes, drew, scenes, animating, rendered, built, rebound, qml (Qt’s microseconds per scene), clocks, gpu_mhz, mem_mhz, pstate, gpu (ok, unsupported, late or disjoint), gpu_us, gpu_prep_us and gpu_out_us. A flip record carries flip (the pass), monitor, seq, at_ns, queued_ns and late, the vblanks it missed. dev/pacing-summary.py reads them.

Installing it

For picking Solium at the login screen rather than starting it from a TTY. This is an install from a checkout on Fedora 44; A Fedora package below builds an RPM of the checkout instead. There is no COPR or PKGBUILD yet (#66).

dev/install.sh                 # build, install into ~/.local, check, print the sudo line
sudo install -Dm644 ~/.local/share/solium/solium.desktop /usr/local/share/wayland-sessions/solium.desktop
dev/install.sh --uninstall     # remove it again; prints `sudo rm -f …` for the session file
--prefix DIRwhere to install, default ~/.local: DIR/bin/solium, DIR/bin/solium-session (what the session file starts: solium --tty --session, and the clean-up after a Solium that crashed), DIR/share/solium/{qml,lua}, and the generated DIR/share/solium/solium.desktop. /usr and /usr/local are system prefixes, below
XDG_CONFIG_HOME=where the session’s other files go, default ~/.config: systemd/user/solium-session.target, systemd/user/solium-autostart.target and xdg-desktop-portal/lilium-portals.conf, copied from dev/session/
--session-dir DIRwhere the display manager reads sessions, default /usr/local/share/wayland-sessions. Only the printed sudo line writes there. Refused with a system prefix
--no-buildinstall the release binary already in target/install
--no-checkskip the installed binary’s --check: for a package built from source, whose binary still finds its build tree during %install and would fail the asset check
--jobs N, --image IMAGEthe build’s cargo jobs (2) and container (localhost/solium-build:fc44)
--uninstallremove bin/solium, bin/solium-session, share/solium/{qml,lua} and the generated solium.desktop under the same --prefix, whatever put them there, and the three files in XDG_CONFIG_HOME only while they are what install wrote
DESTDIR=stage the prefix and XDG_CONFIG_HOME under this directory instead; the session file’s Exec still names the real prefix. --session-dir is used as given, so a test can point it at /tmp. It may contain a ~, as rpmbuild’s buildroot does
SOLIUM_BUILD_LOCK=, SOLIUM_BUILD_MEMORY=the lock the build takes ($XDG_CACHE_HOME/solium-build.lock, or ~/.cache/solium-build.lock when XDG_CACHE_HOME is unset) and the container’s memory cap (6g). dev/build-release.sh reads both, so they apply to dev/rpm.sh too

dev/install-check.sh runs all of that into /tmp and checks every step.

A system prefix, --prefix /usr or --prefix /usr/local, is the layout a package installs, and what the Fedora package’s %install runs. Everything goes under the prefix and nothing into XDG_CONFIG_HOME: the units in DIR/lib/systemd/user, the portal choice in DIR/share/xdg-desktop-portal and the session file in DIR/share/wayland-sessions, where systemd, xdg-desktop-portal and the display manager read system files, so there is no sudo line. Those files are the installation’s, like the binary, so there is no config.sha256 and uninstall removes them whatever put them there. The check after installing runs with an empty configuration directory, because what every user gets is the shipped configuration, not yours.

The units and the portal configuration go beside your own configuration, so they are treated as yours once you change them. Install records a checksum of each file it writes in share/solium/config.sha256, and replaces or removes a file only while it still matches. A file edited since, or a link (a dotfiles checkout, say), is kept, and install and uninstall both name it. Solium starts the targets itself (session.rs), so nothing needs enabling. systemd reads new unit files at the next login, and install prints the systemctl --user daemon-reload that has it read them at once. That command changes nothing that is running, and install does not run it for you.

Why it builds at /solium-src, a path the host does not have. assets.rs tries the build tree before the install layout:

#![allow(unused)]
fn main() {
const BUILD_TREE: &str = env!("CARGO_MANIFEST_DIR");
// …
places.extend(baked.map(PathBuf::from));
places.push(PathBuf::from(build_tree));
// … then "the install layout answering for itself":
places.extend(
    exe.and_then(Path::parent)
        .and_then(Path::parent)
        .map(|prefix| prefix.join("share").join("solium")),
);
}

A binary compiled at the checkout’s own path finds the checkout there, so once installed it would go on reading the checkout’s QML and Lua, and a git checkout would change a running session. Measured: target/debug/solium copied into a prefix with its own share/solium still logs the tree it was compiled in as its shipped assets root, and install.sh’s check refuses it. Compiled at /solium-src in the container, the build tree it names is not on the host, so the installed copy falls through to <prefix>/share/solium. That build goes to its own target/install, apart from the builds made at the checkout’s path. Nothing is baked into SOLIUM_DATADIR, so the same binary works from any prefix, a DESTDIR staging area included.

dev/build-release.sh [--jobs N] [--image IMAGE] is that build on its own (defaults 2 and localhost/solium-build:fc44), and it builds target/install/release/solium. It runs cargo build --release -p solium in the build image, with the checkout mounted at /solium-src and CARGO_TARGET_DIR at /solium-src/target/install, under nice -n 19 ionice -c 3. It holds the shared lock SOLIUM_BUILD_LOCK (default $XDG_CACHE_HOME/solium-build.lock, or ~/.cache/solium-build.lock when that is unset) and caps the container at SOLIUM_BUILD_MEMORY (default 6g, and swap the same). An exit status of 137 means the container ran out of memory, and the script says to run it again with --jobs 1. dev/install.sh runs it (unless --no-build), and so does dev/rpm.sh, so a package holds the same binary as an install from the checkout.

Checked before it starts: a Solium running from the binary it would replace (found through /proc/<pid>/exe, as fuser does; it refuses and stops nothing), checked again once the build is over, since a session can start from the old install while it runs; that the build image exists; and that nothing it would delete is reached through a link. Install replaces share/solium/{qml,lua} and uninstall removes them with rm -rf, so a share/solium left linked to a checkout would lose the checkout’s QML and Lua: both refuse while share/solium is a link, or while it resolves into the checkout. A prefix or a share that is itself a link (moved to another disk) is fine, and the staged session file is written beside its target and renamed over it, so it never writes through a link. After: the installed solium --check has to pass, the gate’s scripts check, and the asset root it logs (RUST_LOG=solium::assets=debug, shipped assets root=…) has to be the installed share/solium. If either fails, the new files are already in place, and it says so and prints the uninstall line. It also warns if the solium on PATH is not the one it installed.

Which display manager, and where it looks. Fedora 44 KDE runs Plasma Login (systemctl status display-manager names plasmalogin.service), a fork of SDDM. /etc/plasmalogin.conf has no session-directory setting; the greeter lists sessions with QStandardPaths::locateAll(GenericDataLocation, "wayland-sessions") (src/frontend/settings/models/sessionmodel.cpp in plasma-login-manager 6.7.5), which is /usr/local/share/wayland-sessions and then /usr/share/wayland-sessions. The default is the /usr/local one, because no package owns it. It only watches directories that existed when it started, and that one does not exist until install -D makes it, so log out after running the sudo line rather than expecting a greeter already on screen to notice.

Getting back. Plasma Login remembers the last session (#RememberLastSession=true in /etc/plasmalogin.conf, commented out at its default), so after one Solium login it offers Solium first: pick Plasma (/usr/share/wayland-sessions/plasma.desktop) to return. Inside Solium, Ctrl+Alt+Backspace ends the session whatever the configuration says, unless the screen is locked, and super+shift+q does in the shipped init.lua. Ctrl+Alt+F3 switches to a text console, to log in and read ~/.local/state/solium/session.log. Solium handles both chords itself (escape in input/mod.rs, with the tests ctrl_alt_f3_asks_for_the_third_terminal and ctrl_alt_backspace_stops_the_compositor), because the kernel stops acting on them once a graphical session owns the VT, so neither helps if Solium itself hangs.

The Exec is absolute (Exec=/home/you/.local/bin/solium-session, which runs the solium --tty --session beside it): Plasma Login starts a session with PATH=/usr/local/bin:/usr/bin:/bin (DefaultPath in /etc/plasmalogin.conf), and whether ~/.local/bin is added after that is up to your shell profile.

Where the log goes. --tty, which solium-session passes, is what turns the log file on (main.rs):

#![allow(unused)]
fn main() {
let log = matches!(backend.as_deref(), Some("--tty"))
    .then(open_log)
    .flatten();
}

and open_log puts it in state_directory(), which is $XDG_STATE_HOME/solium falling back to ~/.local/state/solium, the directory the QML GPU marker files share:

#![allow(unused)]
fn main() {
fn state_directory() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_STATE_HOME")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))
        })?;
    Some(base.join("solium"))
}
}

So ~/.local/state/solium/session.log, appended to by every session. Plasma Login also sends the session’s stderr, which carries the same lines, to ~/.local/share/plasmalogin/wayland-session.log, and truncates that at each login (O_TRUNC in its src/helper/UserSession.cpp).

A Fedora package

A development snapshot of the commit checked out, as an RPM that dnf installs and removes, for Fedora 44 machines that have neither Rust nor the build image:

dev/rpm.sh                     # build, package, check unpacked, print the dnf line
sudo dnf install ./target/rpm/RPMS/x86_64/solium-0.0.0~git<date>.<commit>-1.fc44.x86_64.rpm
sudo dnf remove solium

dev/rpm.sh builds the release binary with dev/build-release.sh, the same container build dev/install.sh runs, then makes the commit’s source tarball and runs rpmbuild -bb --with prebuilt on the host (it needs rpm-build, which the build image does not have) into target/rpm, never ~/rpmbuild. Its options are --jobs N and --image IMAGE, the same as dev/build-release.sh with the same defaults. It needs rpmbuild (from rpm-build), rpm2cpio, cpio, git and podman on the host, and refuses to run on a host that is not Fedora. It takes the Qt version for the Requires from the build image (%_qt6_version). It deletes and rebuilds target/rpm on every run, and refuses if target/rpm is a link. rpmbuild’s whole log is target/rpm/rpmbuild.log, and the unpacked package’s --check log is target/rpm/check.log. It refuses uncommitted changes, because the package is named after its commit (0.0.0~git<commit date>.<short hash>), and a build image of another Fedora release than the host’s. Then it checks the package without installing it: unpacked into target/rpm/unpacked, its solium --check has to pass with an empty configuration and log shipped assets root= that root’s usr/share/solium, which is what the binary finds beside itself once it is /usr/bin/solium. It prints rpmbuild’s warnings, if there are any, the package’s path and the sudo dnf install line.

The package is built for one Fedora release, the build image’s, and the .fc44 in its name says which. Its library dependencies are worked out by rpm from the binary, so on another Fedora 44 machine dnf brings Qt and the rest; another release needs its own build, and COPR is where that will happen.

dev/rpm/solium.spec drives %install through dev/install.sh --no-build --prefix /usr, the system layout, so the package holds exactly what that installs: /usr/bin/{solium,solium-session}, /usr/share/solium/{qml,lua}, /usr/share/wayland-sessions/solium.desktop with Exec=/usr/bin/solium-session, the two units in /usr/lib/systemd/user and lilium-portals.conf in /usr/share/xdg-desktop-portal, plus the licences and two documents. rpmbuild fails on a file one has and the other does not, and dev/install-check.sh’s the Fedora package compares the two on every run, without building a package. It also checks what the spec adds to rpm’s automatic library dependencies: Requires qt6-qtdeclarative (the QtQuick and QtQuick.Shapes modules the shipped QML imports), no older than the build image’s Qt, since rpm’s own dependencies take any Qt 6 (a Fedora 44 installed from the release image and never updated has 6.10, and dnf brings the newer one from updates), xorg-x11-server-Xwayland and util-linux-core (solium-session’s flock), each the package that has the file here, and Recommends xdg-desktop-portal and each backend lilium-portals.conf names, gtk and wlr, and foot: the shipped configuration has no launcher, so super+return’s terminal is how a program starts, and none of the terminals init.lua looks for is on Fedora Workstation.

Without --with prebuilt, %build compiles the tarball with cargo build --locked --release, SOLIUM_DATADIR baked as /usr/share/solium, and %install passes --no-check, since that binary still finds its build tree during the build; %check runs its --check instead. That is the build COPR will run, with internet access for cargo. It has not been run here: the build image has no rpm-build, and the host cannot link Solium.

Use one way of installing, not both. With the package and an install from the checkout, the login screen lists Solium twice, the units in ~/.config come before the package’s, and ~/.local/bin comes before /usr/bin on PATH. dev/rpm.sh warns when it finds one; dev/install.sh --uninstall takes it out.

Does the compositor’s half of the QML GPU path still work?

cd dev/wirecheck && cargo build        # natively, or in the build image
./target/debug/wirecheck               # on the host, which is where the GPU is

dev/gate.sh runs both steps (see “The gate” in dev/README.md), passing the first /dev/dri/renderD* the machine has and writing the output to dev/wirecheck/target/wirecheck.log. Exit status is the result; there is nothing to read unless it fails, and then the gate prints the log’s last 25 lines.

This drives the real crates/solium/qml/host.cpp — compiled from source by build.rs, not linked out of target/, because a glob over target/debug/build/solium-*/out/ silently picks a stale archive — and the real crates/solium/src/qml/target.rs, included by path. Both are relative to this crate, so a worktree or somebody else’s clone checks its own code.

Against them it stands up a genuine smithay GlesRenderer, built the way tty::State::open_gpu builds one, and runs the sequence a hosted scene’s Gpu::element runs on a frame Qt has something new for — through Gpu::sample, Gpu::refresh and Gpu::render:

scene_new_gpu → restore our EGL context → render_gpu → restore
  → EGLFence::import → Renderer::wait → import_dmabuf
  → TextureRenderElement::draw into an offscreen target → read back

That sequence lives in crates/solium/src/qml/paint.rs, which is where the wallpaper, the window frames and the pointer all reach it from — the harness runs the same order by hand because nothing here links the compositor crate.

It needs a GPU and a Qt installation at run time, which is why it is here and not a #[test]. It sets the QT_QPA_EGLFS_* environment itself, the same way qml::keep_qt_off_the_hardware does, so it opens the render node and never the card — it is safe to run inside a live session.

The cases, in the order they run

The sections below are not in this order; this is the order src/main.rs runs them in. Each one stops the run at its first failure, except the byte comparisons of the first frame and the frame loop, which are recorded and judged at the very end.

casewhat it provesfails with
1a child started after Qta child started with a plain Command once Qt is up on the GPU, as smithay starts Xwayland, holds none of Qt’s descriptors, so the byte 15 it writes into each one it holds reaches nothing (#175)a child started after Qt holds descriptors …
2the appear animation (appear.qml)an animation started from rest takes steps, rather than landing in one tickthe appear animation never took a step
3a Timer between frames (timer.qml)with no tick and no render, a 50 ms Timer served the way qml::wake serves one (solium_qml_next_due_ms, then solium_qml_drain) fires at least 6 times in 600 ms, a drain reports the scene it changed, and the compositor’s EGL context is still current after every draina 50ms Timer fired … with no frame drawn, the Timer recoloured the scene …, or solium_qml_drain took the compositor's EGL context off the thread …
4the first frame (quadrants.qml)Qt builds and renders a scene into the compositor’s dmabuf; an independent EGL display, with neither Qt nor smithay in it, sees the pixels; and the picture is byte for byte the software path’san independent EGL display sees nothing in the buffer Qt reported rendering, or the GPU path does not match the software path at the end
5a Timer beside an animation (timer.qml again)between the first frame’s render and the checks of its pixels, with quadrants.qml’s endless animation running, the same Timer still fires at least 6 times in 600 ms on drains alone, and the compositor’s EGL context stays currenta 50ms Timer beside a running animation fired …, nothing is animating, so this proves nothing …, or advancing the animation clock between frames took the compositor's EGL context off the thread
6the pointer’s routethat frame, read back and uploaded as memory, is byte for byte what the software path uploadsreading a scene's dmabuf back does not give the bytes the software path uploads
7frames 2..Nevery later frame, into a buffer wiped first, matches the referencethe GPU path does not match the software path, at the end of the run
8the resizea rebind keeps the object tree and its running animation, the scene reads as animating, the new size draws right, and freeing the scene takes none of the compositor’s GL objectsthe QML tree was rebuilt by a resize, or freeing the resized scene destroyed …
9the compositor’s own scenescursor.qml, panes/top/Frame.qml and delegate.qml build and draw on a GPU host; the first two read as not animating, and delegate.qml, whose one animation is in a Repeater delegate, reads as animatinga GPU host could not build the …, or `solium_qml_scene_animating` says …
10the pointer’s sizecursor.qml at 16, 24, 48 and 96 draws from the corner and fills the same fraction at eachthe pointer fills …
11a pointer scene’s glow (glow.qml)a scene sized by its own root, as cursor.scene is (#213), keeps its root’s 48x48 on the 24-pixel buffer it is built on and is rebound onto 48, and its MultiEffect draws a glow beside its squarea scene sized by its root read …, or MultiEffect drew no glow beside the square on the GPU path
12the rounded-corner shaderit compiles in all three variants, and draws the middle intact, all four corners cut, four radii in the right quadrants and a zero radius squarethe rounded-corner shader did not compile, or a message naming the variant or corner
11bGPU timestamps (FX0)GL_EXT_disjoint_timer_query is found, both entry points load, and 200 fills of 1024² in one pass resolve within 20 polls of the timer’s idle, 10 ms apart, to 100 µs ≤ ns < 1 s: the two timestamps bracket the fills, which a region closed before them (about 1 µs) does not. Second half: ten passes stamped as the TTY stamps an output (open before a frame, close after its finish, then the frame’s fence waited on as the kernel waits on it before the flip, which flushes nothing) each resolve at the first idle 10 ms later, however slow the GPUGL_EXT_disjoint_timer_query is not usable here…, pass 1's GPU time did not resolve…, or passes […] were not resolved at the first idle…
11ca capture sampled with no CPU wait, through smithay (FX0)100 rounds of 500 fills into 1024², the fence dropped, sampled at once with render_texture_from_to: every pixel is the round’s colourround N: a capture sampled with no CPU wait read pixels it had not finished
11dthe same through raw GL, as the warp samples (FX0)the same, sampled with the raw texture name in a program of its own, no glWaitSyncround N: a capture sampled through raw GL …
11ethe warp program (FX0)warp/gl.rs’s program compiles and draws a texture through a full-target meshthe warp drew … at the centre, not the texture
11fa pooled target (FX0)pool.rs’s target is drawn through its own framebuffer object in a frame opened on a 1x1 carrier, and the carrier keeps its blue; painted again with nothing, the target reads back transparentthe pooled target is not what was drawn into it, a pooled target painted with nothing is not transparent, the carrier is no longer blue…
11gpartial damage on a warp (FX0)a translucent warp redrawn under one damage rectangle leaves the pixels outside it as they were… blended twice
11hthe clipped-surface programs (FX0)both compile in every variant; a root surface’s corners are cut, a subsurface is cut only where its corner is the client’s, a single-pixel buffer is cuta message naming the surface and the pixel
13the first rebinda scene built at 1x1 and never rendered rebinds and draws its new buffer righta scene rebound before it had ever rendered does not draw its new buffer
14build and freea scene built and freed without rendering takes none of the compositor’s GL objectsbuilding and freeing a scene without rendering destroyed …
15C-1a scene freed with the compositor’s context current takes none of the compositor’s GL objects, by a census of GL namesQt's teardown destroyed …

C-1 is the name the harness prints for case 15 (=== C-1: free a scene with the compositor's context current ===), and it is the name the controls below use.

What it asserts, and why each one is not obvious

The picture matches the software path, byte for byte. Not “looks right”: the same known image is uploaded through import_memory — which is top-down by definition and is what the software shell path uses — and drawn through identical element parameters. Comparing the two readbacks cancels whatever convention the offscreen target itself has, which is the only way to check orientation without asserting one. The gate runs it at scale 1 only; run it at WIRECHECK_SCALE=2 and 1.25 too after touching the element transform, because the element’s src is in device pixels and its size is logical, and a transform that is right at 1 can be wrong everywhere else.

Frames 2..N, not just the first. The first frame after a scene is built is safe under bugs the rest are not, because initialize() left Qt’s context current and nothing has taken it yet. Every one-frame probe this project has written passed while the second frame was broken.

A resize keeps the QML object tree. solium_qml_scene_rebind puts a scene on a new buffer without rebuilding it, and the cost of the alternative was never mainly the allocation: a rebuilt scene is a new object tree, so every animation, transition and stored property in it restarts from zero. A pane is sized from an animating rectangle, so that happened once per frame for the length of every window animation — an animation restarted every frame never advances. Asserted with a counter quadrants.qml holds and nothing outside the tree remembers, so a fresh tree hands back the declared default; and then with the picture at the new size, through the same reference comparison as above, because a rebind that returned true and left Qt on the old texture would carry the counter across perfectly.

And a running animation keeps running across it. The counter is a proxy and was always only a proxy: a tree that survived with every animation reset to its from: carries the counter across perfectly and is still the bug. So quadrants.qml also holds a NumberAnimation on spin, the harness advances the compositor’s clock with solium_qml_tick once per frame the way render::prepare does, and the case asserts spin is larger after the rebind than before it. Two instrument checks go with it, because a property that never moves reads the same number twice whichever way the rebind went: spin must be non-zero before the rebind (or nothing was animating and nothing was ticking), and WIRECHECK_STOP_THE_CLOCK below is its negative control.

Measured at scale 1: spin reads 80 after five ticked frames and 96 after the rebind and one more. Under WIRECHECK_REBUILD_ON_RESIZE it reads 80 -> 0, which is printed in that control’s own failure message.

The pointer and a pane layer, on a GPU host. The compositor’s real qml/cursor.qml and qml/panes/top/Frame.qml, built through solium_qml_scene_new_gpu and rendered. Before they moved to the GPU path, those two went down the software constructor, which a GPU host refuses outright — so SOLIUM_QML_GPU=1 gave a desktop with a wallpaper on it and no window frames and no pointer, each refusal logged by its own caller as its own unrelated failure.

The files themselves and not a stand-in, because what is in question is whether these come up under the RHI scene graph: cursor.qml draws through QtQuick.Shapes with the curve renderer and panes/top/Frame.qml puts glyphs through a text atlas, neither of which the four flat rectangles in quadrants.qml touch. The byte count cannot see the glyphs — they are drawn inside an opaque band — so what it measures is the band, and the glyph path is exercised rather than asserted.

A pane layer has to be dressed before it draws at all. A style’s insets live in its Pane.qml and are written onto each layer, so a Frame.qml built standalone has insetTop at 0 — a bar of no height, with the title centred in it and the hairline on top of it. Measured, that left 544 of 1228800 bytes non-zero against 81920 before the conversion, so the check went on passing while seeing almost nothing. It now writes insetTop and title first, which is what the compositor writes, and the bar and its glyphs come back.

What it cannot check is the picture — there is no reference for a titlebar here, and inventing one would assert today’s design system rather than the path — so it wipes the buffer through the compositor’s own renderer first and asks whether anything came back. That proves Qt built the component, brought up an RHI for it, imported our dmabuf and wrote into it. It proves nothing about what it drew.

It does not stand in for the defect on the Rust side. Nothing in this binary links the compositor crate, so which constructor cursor.rs calls is invisible here; what stops that regressing is that Scene::gpu_sized and Scene::with_properties are now private to qml.rs and Scene::for_host is the only way in from outside it.

And that the pointer is drawn at the size it was given. cursor.qml is built at 16, 24, 48 and 96, and what is measured is the bounding box of everything drawn in a buffer wiped transparent first. The arrow is laid out in a 24-unit square scaled to the item, so it fills the same fraction of the buffer at every size — 0.60 across by 0.83 down geometrically, 0.61–0.62 by 0.83–0.88 as read back, the top of that spread being the small buffers where an antialiased edge is a larger share of the box.

This is the one claim in #81 that nothing else in the gate could see, in either direction. cursor.rs sizes the scene to the configured cursor.size and host.cpp puts that logical size on the root item; while the arrow was drawn in literal units in a hard-coded 24-unit item it ignored both, so size = 48 padded the buffer around a 24-pixel arrow and XCURSOR_SIZE=16 cut the arrow’s tail off. cargo test cannot ask — the_configured_size_reaches_the_qml_pointer_scaled asserts the arithmetic Cursor::element does and nothing about what Qt then draws, so it passed through both — and neither can the byte count above, because a 24-pixel arrow in a 48-pixel buffer is exactly as many non-zero bytes as a 24-pixel arrow in a 24-pixel one.

The two sizes on the ends are what make it an instrument rather than a snapshot of today. Measured both ways in one process, against the pre-fix cursor.qml with nothing else in the tree changed:

16x16: (0, 0)..(14, 15) -- 0.94 across, 1.00 down   [clipped]
48x48: (0, 0)..(14, 20) -- 0.31 across, 0.44 down   [inert]

At 24 alone it passes, which is exactly how this shipped. The bounding box also has to start at (0, 0) at every size: cursor.rs places the buffer by subtracting HOTSPOT, which is (0, 0), so the arrow’s tip has to be in the corner or the pointer points a few pixels away from what it is over.

And that a pointer scene’s glow is drawn. cursor.scene (#213) is a scene sized by its own root, and MultiEffect is a shader, so it draws on this path and nothing on the software one; cargo test has no GPU and a nested run has no GBM device, so this is the only place it can be seen. glow.qml is a 48x48 root with a white 16-pixel square in the middle and a MultiEffect shadow of it. It is built on a 24-pixel buffer, as Cursor::configured builds a scene at cursor.size, where its root has to still read 48x48, and then rebound onto a 48-pixel one, as the first Gpu::sample rebinds it. What is read back is the alpha inside the square, 255, and four pixels beside it, which only the glow can reach: 27 on this machine. Measured with size_root in host.cpp writing every root, as it did before, the root reads 24x24 and the case fails.

And whether the host can say that a scene is animating — both ways. solium_qml_scene_animating, which is what render::Drawn gates the next frame on. It is a claim about Qt and not about anything in this repository: that a QQuickAbstractAnimation driven by a Behavior or a NumberAnimation on sets its own running property and clears it when it is done. cargo test cannot ask — asking needs a live Qt — and the compositor’s whole animation loop hangs on the answer, because Qt’s dirty flag means “something changed”, which a running animation does not do on every tick.

The two readings are taken in one run, in one process, against one driver, and they are this instrument’s negative control: no edited copy of anything is needed, because a stub cannot satisfy both. quadrants.qml carries an animation with loops: Animation.Infinite and is asserted to read 1, in the resize case, on the same line that already asserts spin has moved. cursor.qml and panes/top/Frame.qml are built and rendered in the scene case above and are asserted to read 0. The pane layer is written to — it draws nothing otherwise, see below — but only on insetTop and title, neither of which carries a Behavior. That is the sharper claim, and a Behavior added to either in Frame.qml would red this line.

Verified by stubbing solium_qml_scene_animating’s return in a control copy, both ways, with the harness otherwise untouched:

return 1;  ->  Error: `solium_qml_scene_animating` says the cursor scene
               (crates/solium/qml/cursor.qml) is animating, and nothing has
               written a property on it or declared an animation in it.
return 0;  ->  Error: `solium_qml_scene_animating` says nothing is animating in
               a scene whose `spin` just moved 80 units under an
               `Animation.Infinite`.

The cursor.qml reading is the one that matters most, and it is there to rule out a specific wrong answer rather than a hypothetical one. QAnimationDriver::isRunning() reads like the direct question and is not: a driver is one object for the whole process, and quadrants.qml has been animating since long before the scene case runs, so a process-wide answer is true on that line. This case fails on it. Before host.cpp kept a pause registered for the life of the process it was worse: QAnimationDriver::advanceAnimation ends in QUnifiedTimer::localRestart, which starts the driver again whenever it is not running and nothing is registered at all (qtbase v6.11.2, src/corelib/animation/qabstractanimation.cpp:333), so the answer was true for ever, and a compositor gated on it never slept again — measured then at 400 frames of 400, never idle, once anything else damaged the screen while an animation was finishing.

Neither assertion allocates a GL object, so the teardown control’s destroyed list is unmoved by them: re-measured after adding this case, C-1 still reports the same [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)].

And whether an animation started from rest actually runs. The question both of the readings above are blind to, and the one the hardware was still failing after them: an animation advanced past its own end in a single tick satisfies dirty and solium_qml_scene_animating perfectly. dirty is raised, because the value did change; animating is true on the frame the Behavior starts and false a tick later, because the animation really has finished. The compositor then asks for exactly the frames it should, and every one of them shows the final value — a titlebar that does not slide out, it is simply there.

So this case reads the animated value itself. appear.qml beside this file is one property with a Behavior on it, sliding 34 units over 260ms on a linear curve, mirrored into an int so it can be read back out of the object tree. It is settled for forty frames, pointerInside is written the way Decoration::tell writes it, and the twenty readings that follow must include at least one strictly between the two ends. Not that it reaches its end — it does that either way, instantly, which is the bug.

It has to run first, before any other scene exists, and that is not a stylistic choice. Qt takes the driver’s elapsed() into its own clock only when the driver starts (QUnifiedTimer::startAnimationDriver, qtbase v6.11.2, src/corelib/animation/qabstractanimation.cpp:242-266), which is on the edge from nothing on the clock to something — host.cpp keeps a pause registered so that the driver stops at all — and quadrants.qml’s Animation.Infinite keeps the driver running from the moment it is built until the process exits. On any later line the edge never happens, every delta is 16ms, and this case passes with the defect fully present. The precondition, a stopped driver, is asserted through wirecheck_anything_animating rather than left to a comment, so a case added ahead of it fails loudly instead of quietly making this one vacuous.

It also has to settle for long enough that the clock passes the animation’s own duration. A harness whose clock starts at zero cannot see this defect at all, because the bad delta is the clock: the forty settling frames put it at 640ms, and a desktop where somebody hovers a window has been up for minutes.

The clock control — put the driver back to reporting the compositor’s uptime, which is a two-line diff and touches nothing else:

cd dev/wirecheck
sed -e 's|return m_elapsed - m_origin;|return m_elapsed; /* control */|' \
    -e 's|^            m_origin = elapsed;$|            /* control */ (void) anything_animating;|' \
    ../../crates/solium/qml/host.cpp > host-control-clock.cpp

WIRECHECK_HOST_CPP="$PWD/host-control-clock.cpp" cargo build --target-dir target-control-clock
./target-control-clock/debug/wirecheck   # must fail
  slid, frame by frame: [-34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  readings strictly between -34 and 0: 0
Error: the appear animation never took a step: ... from -34 to 0 with nothing
in between, over 20 frames of a 260ms animation.

against the shipped file’s

  slid, frame by frame: [-34, -30, -28, -26, -24, -21, -19, -17, -15, -13, -11, -9, -7, -5, -3, -1, 0, 0, 0, 0]
  readings strictly between -34 and 0: 15

Both runs print scene animating = true on the frame that writes the property. That is the point of printing it: the reading the previous case exists for separates nothing here, and neither does dirty. The control’s scene is running on that frame and settled one tick later, which is exactly what a healthy animation that has just finished looks like — because it has.

The scene is kept alive rather than freed, like the two in the scene case below and for the same reason — a free here would reach C-1’s defect in C-1’s own ordering, ahead of C-1’s census.

The pointer’s route: the dmabuf read back and uploaded as memory. The one scene that does not reach the screen as a texture. smithay reaches a DRM cursor plane only through RenderElement::underlying_storage, whose two variants are Wayland and Memory (renderer/element/mod.rs:103-109); a TextureRenderElement implements none and inherits None, so copy_element_to_cursor_bo gives up on its first line. And nothing catches it underneath: smithay’s pixman fallback is behind #[cfg(feature = "renderer_pixman")], which is not in the compositor’s feature list (crates/solium/Cargo.toml:24-43), so the arm that compiles is the #[cfg(not(...))] one at drm/compositor/mod.rs:3244 and its failure branch is a plain return None. A GPU pointer drawn as a texture loses the plane silently, and every pointer motion on a TTY becomes a full composite and page flip. So cursor.rs lets Qt draw into the dmabuf and then reads it straight back into a MemoryRenderBuffer.

That hangs on a claim nobody had checked: what copy_framebuffer hands back is byte-identical to what import_memory is given on the software path. Three conventions meet there — Qt’s render target, which mirror_for_the_compositor flips; smithay’s readback; and MemoryRenderBuffer’s top-down rows — and two of them cancelling is not the same as all three agreeing. Checked against the known picture directly and then through an element built from it.

It sits before the frame loop, deliberately, and reads the first frame: that is the one frame every configuration of this harness draws correctly, so this case fails only on its own defect. Reading a buffer the frame loop had already found wrong would make it fail on the loop’s defect and report it as a readback fault — and an all-zero buffer, which is what the render control leaves, is called out separately and sent back to the frame comparison rather than described as a flipped or swizzled one.

It also prints what the round trip costs, because the whole argument for the route is that the pointer pays it once per size per change rather than per frame. The cost is ~65 µs fixed plus 3.5–4 ns per pixel on this machine — fitted over a sweep rather than read off a pair, since one run at two sizes cannot separate the two terms:

sizepixelseach
24x2457666.9 µs
48x48230469.1 µs
96x969216110.8 µs
192x19236864192.3 µs
384x384147456605.8 µs

The pixel term is negligible at cursor sizes and only at cursor sizes. At 24x24 it is ~2 µs of ~67, three per cent, so what the pointer pays really is the round trip — and the Kept cache in cursor.rs makes even that once per size per change. Read that scoped, because it expires immediately above the pointer: at 384x384 the pixel term alone is ~540 µs and the whole call is 606, nine times what the cursor pays for its entire readback. “It is the round trip, not the pixels” is a claim about 24- and 48-pixel squares, and is not a reason to trim the cache.

Set WIRECHECK_LOGICAL and WIRECHECK_SCALE to move the size this reports, and re-run it several times at one size before comparing two: the spread within a single size is wider than the gap between 24 and 48. Seven runs each gave medians of 68.0 µs at 24x24 and 67.1 at 48x48, with 24x24 spanning 60.2–72.1 and 48x48 spanning 65.6–80.2. This file previously reported a single run in which 24x24 (63.5 µs) came out slower than 48x48 (59.8 µs) and treated that inversion as evidence; it was noise, and at n=1 there was nothing there to read.

If the fixed term were ever milliseconds, the cache would not be enough and the design would need revisiting — which is why the number is printed rather than remembered.

The first rebind, on a scene that has never rendered. The shape production is actually in, and the one the resize case above cannot reach — it renders five frames first. Nothing on screen does: ShellSurface::new builds at 1x1 with its size recorded as (0, 0), and a Decoration is built at the client’s size and has to end up on the outer rect at the monitor’s scale. Both take the rebind branch on their very first frame, with QQuickRenderControl::initialize() the only thing that has ever made the scene’s context current — so take_the_thread is working from what the build recorded, and the thread-local clear_stale_current_context reasons about is either null or another scene’s. Checked with the same reference comparison as the resize case, because a rebind that returned true and left Qt on the 1x1 texture would sail past a non-zero check.

Neither of those two cases frees its scenes. That is deliberate: a free with the compositor’s context current is C-1’s own ordering, so freeing here would reach that defect before C-1’s census is taken — and under the teardown control with WIRECHECK_KEEP_RESIZED_SCENE set it would stop the run in the resize case’s place, putting C-1 back to only ever executing in the passing state. Nothing is leaked past the run; Qt keeps its own reference to each buffer and the process is about to exit. Verified: with those cases in place, the teardown control still reaches C-1 and C-1 still reports the identical five objects.

That byte comparison is not a second check on the render path, and it is worth knowing before anyone trims the frame loop on the strength of it. Measured under the render control: the resize case reports 0 of 65536 bytes differ and passes, three runs of three, while the frame loop above it reports frame 2: 10240 of 16384 bytes differ and fails the run. It cannot see that defect and it is right not to — release_the_thread at the end of a rebind leaves the thread empty, so the beginFrame after one correctly takes it whether or not solium_qml_scene_render_gpu clears a stale belief first. The frame loop is still the only thing that catches it.

A scene built and freed without ever rendering. The path that reaches clear_stale_current_context’s null-thread-local early return. It used to be the hot path by accident, when every resize built one scene and freed another; now that a resize does neither, this case is the only thing that takes it on the free side.

A scene freed with the compositor’s context current — the ordering the rest of the harness cannot produce, since building the replacement first leaves the new scene’s context current and Qt then does the right thing by accident.

That last one is checked with a census of live GL object names (glIsTexture/glIsBuffer/glIsFramebuffer/glIsProgram/glIsRenderbuffer over 1..64) in the compositor’s context, taken before and after the free and diffed. A third census is taken before Qt is started at all, so anything destroyed that appears in it is unambiguously the compositor’s.

The census is the assertion and it was not the first thing tried. Guard textures drawn and compared across the free once passed with the bug present and absent, because Qt’s deferred releases for that scene were buffers and a framebuffer and a texture-only check cannot see a glDeleteBuffers.

Do not read them as an expectation in either direction. Whether a guard is visibly clobbered depends on Qt’s deferred deletes landing on the integers the compositor’s own textures happen to have been given, and that collision is not stable run to run. The census is: it has been identical in every run of every configuration anyone has logged here.

The frequency, since a number is what stops the guessing. Sample: 30 consecutive runs on this machine of the one configuration this file gives an Expect for (WIRECHECK_KEEP_RESIZED_SCENE=1, below). The guards fired in 5 of 30, and every one of those five fired completely — => 0 of 8 compositor textures survived, (8 of 8 guard textures visibly clobbered). The other 25 read => 8 of 8 compositor textures survived and (0 of 8 …). All 30 failed on the census, with the identical five objects and the identical attribution to [('b', 1), ('b', 2)].

Quote the sample size alongside the number, because this number moves. This file previously reported 0 of 8 in 30 of 30 here, and 6 of 8 when they fire elsewhere, and neither reproduces: the magnitude measured now is all-or-nothing. That is not a regression, it is the paragraph above being true. What Qt’s deferred deletes destroy depends on which integers the compositor’s own objects happen to have by the time the free runs, and every case added ahead of C-1 since those runs shifts exactly that. Expect the next case added here to move it again — re-measure rather than reasoning from this paragraph.

So neither reading is a finding. A control run reporting 8 of 8 compositor textures survived has not stopped working, and one reporting 0 of 8 compositor textures survived has not found a new bug — it is the same defect, caught by an instrument that only sometimes has anything to catch it with. Check the census. The guards illustrate what the damage means; the census is the proof that it happened.

The free-path case also asserts its own precondition, through wirecheck_belief_names_scene: Qt’s thread-local must name this scene’s context and EGL must disagree. Neither half alone is enough. A null belief is safe with or without the fix, and so is a belief naming a different live scene, because ensureContext() compares it against its own ctx and corrects. The first version of that guard asked only whether some belief existed and passed with the fix reverted.

Negative controls

A harness that passes both ways proves nothing, and this one has been in that state five times: four blind instruments, and once a control that could not observe the thing it named. A sixth was a different shape and is worth knowing about separately — the instrument was truthful and this file overclaimed it, so a documented expectation that reproduced about one run in six taught the reader to distrust a working control. Hence the frequencies quoted below rather than whichever result the last run happened to give.

clear_stale_current_context has two call sites and each guards a different defect, so there are two controls there and both are worth running after any change here.

Keep the copies inside the repository. The build runs in the container, which mounts $HOME and nothing else, so a control under /tmp compiles to fatal error: no such file. Both filenames and both target directories are gitignored.

The teardown control — drop the call in solium_qml_scene_free, leaving the one in solium_qml_scene_render_gpu:

cd dev/wirecheck
awk '/^extern "C" void solium_qml_scene_free/ {f=1}
     /^extern "C" void solium_qml_scene_resize/ {f=0}
     f && /clear_stale_current_context\(scene\);/ {print "    /* control */"; next}
     {print}' ../../crates/solium/qml/host.cpp > host-control.cpp

WIRECHECK_HOST_CPP="$PWD/host-control.cpp" cargo build --target-dir target-control
./target-control/debug/wirecheck        # must fail

Expect DESTROYED in our context by freeing the resized scene: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)], of which buffers 1 and 2 appear in the pre-Qt census. Measured identical in 5 of 5 runs.

That list was seven objects until the pointer’s readback case was added above it — ('f', 2) is no longer among them — and the reason is worth knowing rather than being surprised by later. What Qt’s deferred deletes destroy depends on which integers the compositor’s own objects happen to have been given by the time the free runs, and any case added before this one shifts that. So the list is illustrative and the emptiness is the assertion: the harness fails on !lost.is_empty(), not on a particular set. Re-measure it after adding a case here rather than treating a changed list as a finding. The pre-Qt census below has not moved.

Two of them and not three, and that distinction is the whole value of the line. The pre-Qt census is [('b', 1), ('b', 2), ('p', 3), ('p', 4), ('p', 5), ('p', 6), ('p', 7), ('p', 8), ('p', 9)] — invariant across 25 logged runs of every configuration. ('p', 3) is a program named 3, and programs and buffers are separate GL namespaces, so there is no buffer 3 for Qt to have destroyed: ('b', 3) is Qt’s own.

That comparison is yours to make in this run, and the two lines to make it from are both printed: the pre-Qt census above, and the DESTROYED … list. This run does not attribute them for you. It ends:

  DESTROYED in our context by freeing the resized scene: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('r', 2), ('b', 3)]
Error: freeing the resized scene destroyed 6 of the compositor's GL objects

and that is the whole of it. The attributed line — ...of which existed before Qt was started, so are certainly ours — is printed at src/main.rs:1603, which is inside C-1, and this run never reaches C-1. Do not go looking for it here; read it off the WIRECHECK_KEEP_RESIZED_SCENE=1 run below, which does print it, against the same unchanged pre-Qt census.

That is the resize case failing, not C-1: the resized scene is freed in exactly C-1’s ordering, so it reaches the same defect first and the run stops there — C-1 was not reached in any of those 6 runs. Run the control a second time with the free skipped, so C-1 executes its own instrument:

WIRECHECK_KEEP_RESIZED_SCENE=1 ./target-control/debug/wirecheck   # must fail

Expect DESTROYED by Qt's teardown, in our context: [('b', 1), ('f', 1), ('r', 1), ('b', 2), ('f', 2)] and ...of which existed before Qt was started, so are certainly ours: [('b', 1), ('b', 2)] — identical in 30 of 30 runs on this machine. The guard line is not part of the expectation: across those same 30 it read 8 of 8 compositor textures survived in 25 and 0 of 8 in 5, with nothing in between, and see the guards paragraph above for why either is neither surprising nor a problem.

Both runs matter and they check different things: the first that the defect is caught, the second that C-1’s own census, precondition assertion and guard textures still execute at all. Without the knob they only ever run in the passing state, where a regression in the instrument itself would be invisible.

Two things measurement settled against what was written here first, both of which had been reasoned rather than run:

  • C-1 does not depend on the resize case’s census to protect its baseline. With that free skipped, C-1’s diff is the full five objects rather than a clean one. The resize case’s census earns its place by catching the damage where it happens, not by shielding C-1.
  • “Most of the guard textures clobbered outright” was right that the guards are unreliable and has now been wrong twice about the magnitude — first “most of them”, then “6 of 8”. Measured here across 30 runs of this configuration: they fire in 5, and when they fire it is all eight. The reliability half is the durable part; the magnitude is whatever this build’s GL name allocation happens to produce.

The render control — drop the call in solium_qml_scene_render_gpu instead:

awk '/^extern "C" int solium_qml_scene_render_gpu/ {f=1}
     f && /clear_stale_current_context\(scene\);/ {print "    /* control */"; f=0; next}
     {print}' ../../crates/solium/qml/host.cpp > host-control-render.cpp

WIRECHECK_HOST_CPP="$PWD/host-control-render.cpp" cargo build --target-dir target-control-render
./target-control-render/debug/wirecheck  # must fail

Expect frame 2: 10240 of 16384 bytes differ from the reference, 10240 being the reference’s exact non-zero byte count — Qt issues the frame against the compositor’s context and writes nothing, so what is read back is the wipe.

That line is the expectation, not the exit: the frame loop only records, and worst is checked at the very end of the run.

It used to stop well before that, at the resize case, with the resize left GL error 0x501 in the compositor's context — and that was the harness’s fault, not the control’s. glGetError pops one error per call, and none of the four probes drained before the operation they were probing, so an error generated hundreds of lines earlier was reported against the rebind and the reader was sent to code that is fine. Each probed operation is now bracketed: drain_gl_errors empties the queue immediately before it and prints anything it found as not the operation’s, and the probe reads once immediately after. Under this control the run now prints GL error(s) ["0x501"] already pending BEFORE the rebind and glGetError after it: 0x0, and goes on to fail where it should, on the GPU path does not match the software path.

Dormant on this machine in the passing configuration — 3 of 3 runs drain nothing — so this is an instrument that was wrong rather than one that was failing.

That control only works because of the wipe. quadrants.qml paints an unchanging picture, so before the wipe existed the dmabuf still held frame 1’s identical pixels and the comparison read zero: “Qt did not write” and “Qt wrote the same thing” were the same measurement, and this control exited 0 with the defect present. Do not remove the wipe, and do not make the probe scene static in a way that survives it.

Verify the substitution reached the binary

Two different questions, and for a long time only the first was asked here:

# 1. Was the control written? Exactly one changed line each.
diff ../../crates/solium/qml/host.cpp host-control.cpp
diff ../../crates/solium/qml/host.cpp host-control-render.cpp

# 2. Was the binary built from it? The build must be NEWER than the file.
for c in control control-render; do
    if [ "target-$c/debug/wirecheck" -nt "host-$c.cpp" ]; then
        echo "$c: binary is newer than the control it was built from"
    else
        echo "$c: STALE — this binary predates its own control"
    fi
done

The second check is not pedantry, it is a bug this harness actually had. build.rs emitted rerun-if-changed for host.cpp and rerun-if-env-changed for WIRECHECK_HOST_CPP, but nothing for the file that variable points at. So editing a control copy while host.cpp was untouched changed neither, cargo never re-ran the build script, and the stale object file was linked.

Measured on this machine, with that line missing: host-control-render.cpp was overwritten with a byte-identical copy of host.cpp — no control in it at all — and rebuilt with the same env var and target directory. The build reported Finished ... in 0.04s and compiled nothing, diff then declared the two files identical, and the binary went on failing with frame 2: 10240 of 16384 bytes differ — biting on a control that was no longer in its source. With the rerun-if-changed for host restored, the identical sequence recompiled (Compiling wirecheck, 6.5s) and the run came back EXIT=0, frame 2: 0 of 16384 bytes differ, which is the correct answer for a binary with no control in it.

strings on the binary cannot substitute for this: the awk replaces a call with a comment, and neither is a string literal, so nothing distinguishes the two binaries by content. The timestamp is what answers “was this built from what is on disk now”.

The animation control needs no copy of anything either. WIRECHECK_STOP_THE_CLOCK=1 leaves the rebind alone and simply stops ticking from the rebind onward, so the tree is kept, the counter still counts, and the animation assertion has to fail on its own:

WIRECHECK_STOP_THE_CLOCK=1 ./target/debug/wirecheck   # must fail

Expect `spin` went 80 -> 80 across a rebind and a frame the clock was deliberately stopped for, with the counter reads 6 on the line above it — the counter passing is half the point, since it is what shows the two instruments are independent. The message is a different one from the unticked case’s on purpose: saying “across a ticked frame” under a control whose whole content is that the frame was not ticked is the harness lying about its own run.

The resize control needs no copy of anything, because what it inverts is a choice and not a line of C++. WIRECHECK_REBUILD_ON_RESIZE=1 answers the resize the way render_on_gpu used to — a new scene on the new buffer, the old one freed after it exists — instead of calling solium_qml_scene_rebind:

WIRECHECK_REBUILD_ON_RESIZE=1 ./target/debug/wirecheck   # must fail

Expect the QML tree was rebuilt by a resize: frames went 5 -> 1. Run it at more than one scale; it has been checked at 1, 2 and 1.25.

It is a knob here rather than a revert because nothing in this binary links the compositor crate. The choice between rebinding and rebuilding is made in Gpu::render, in crates/solium/src/qml/paint.rs: one comparison of the size the scene is bound at against the size asked for, then rebind_sized on a new buffer. Changing it there changes nothing this runs.

The fence control. WIRECHECK_ONLY=fx0 WIRECHECK_FX0_CROSS=1 reads the same draw from an independent EGL display with no wait, and says in how many of 100 rounds it saw an unfinished picture: the race 11c and 11d would see if GL’s one-context order did not hold. The readback prints as it goes, and its lines and the verdict can interleave in a pipe, so read the verdict with grep -o 'a reader on another display saw.*rounds'. Measured on NVIDIA: 0 of 100. Each round’s reader brings up a display of its own before it reads (about 50 ms, against about 4 ms for the 500 fills), so the draw is done before the read starts, and 11c and 11d are regression guards rather than cases this control has shown can fail.

Knobs

argv[1]render node, default /dev/dri/renderD128; honoured by the independent readback too, which used to hardcode it. dev/gate.sh passes the first /dev/dri/renderD* the machine has
WIRECHECK_SCALE, WIRECHECK_LOGICALthe scale and logical size to run at, default 1 and 64. The gate sets neither
WIRECHECK_FRAMESthe number of the last frame the frame loop draws, default 3: frames 2 and 3 are compared after the first, and 1 compares none
WIRECHECK_QMLthe scene the first frame, the frame loop, the resize, the first rebind, build-and-free and C-1 use, default quadrants.qml beside this file. Every one of them compares against the quadrants picture, so another file fails them by design
WIRECHECK_HOST_CPPa different host.cpp, for the controls above; host-control-clock.cpp beside this file is the appear case’s
WIRECHECK_REBUILD_ON_RESIZErebuild the scene on a resize instead of rebinding it — the resize control above
WIRECHECK_STOP_THE_CLOCKstop ticking from the rebind onward — the animation control above
WIRECHECK_KEEP_RESIZED_SCENEdo not free the resized scene, so the teardown control reaches C-1
WIRECHECK_ONLY=fx0runs only the FX0 cases (11b onwards), with no Qt started; that is how the Surface Pro 7 runs them from a copied binary
WIRECHECK_FX0_CROSSwith WIRECHECK_ONLY=fx0, also runs the fence control above
WIRECHECK_RESTORE_EARLY=0skip the restore after scene_new_gpu
WIRECHECK_NO_RESTOREskip both restores of the compositor’s context around the first scene’s build and first render, the early one included. Every restore after that still runs
WIRECHECK_LATE_RENDERER, WIRECHECK_SEPARATE_GBMbuild the renderer after Qt, or on its own device
WIRECHECK_RENDERER_NODEthe node the renderer’s own device comes from, with WIRECHECK_SEPARATE_GBM; defaults to argv[1]

The last four exist because each was once a hypothesis for a failure that turned out to be something else, and re-testing them is cheaper than arguing about them again.

Does this Qt adopt a foreign EGL context?

The whole GPU path for in-compositor QML turned on one call. If Qt can adopt the compositor’s EGL context, it can render into a texture we sample directly. If it cannot, Qt renders on a context of its own and the only way across is a shared buffer.

Since then (2026-09-30). The answer was no: QEGLContext::fromNative returns null under both offscreen and eglfs (see the QML spike). So the GPU path was built on a shared buffer (#94), and it has been the default since #147. Whether that path works on a given machine is now answered by solium --probe-qml-gpu, which the compositor also runs in a child process at every start on the hardware under the default auto renderer (dev/README.md, QML on the GPU). This probe is for the question in its title: re-run it after a Qt update and read the fromNative: line. The exit status does not report that line; it is 0 when the GBM-paired import below worked, 2 or 3 when EGL or a context could not be set up, and 4 when the import failed. The probe opens /dev/dri/renderD128 itself rather than looking the render node up, so on a machine with two GPUs it may test the wrong one.

From dev/qtprobe:

podman run --rm --userns=keep-id --security-opt label=disable -v "$HOME:$HOME" -w "$PWD" \
    localhost/solium-build:fc44 \
    sh -c 'g++ -fPIC probe.cpp -o probe $(pkg-config --cflags --libs Qt6Gui gbm egl glesv2) -lEGL'
QT_QPA_PLATFORM=offscreen ./probe
QT_QPA_PLATFORM=eglfs ./probe

This exists because the answer has now been derived twice, and it is a property of the installed Qt rather than of anything in this repository — so it is worth re-running after a Qt update rather than reasoning about.

dmabuf round-trip

2026-09-08: yes — a GBM buffer can be imported as an EGLImage and bound to a texture, provided the importing EGLDisplay is a GBM-platform display on the same GPU as the buffer. That pairing matters: it is not the default EGLDisplay, and getting it wrong looks exactly like the feature being unsupported. Read this whole section before re-deriving it — the pairing is the part that is easy to get wrong quietly.

The probe pairs the display with the buffer’s own gbm_device, the strictest form of that rule. The first real use of the import, the same evening, measured the looser form Solium depends on: Qt’s eglfs display, opened on the card node, accepted a buffer allocated through the render node, so the pairing is per GPU, not per gbm_device object or per DRM node (the comment above import_dmabuf_texture in crates/solium/qml/host.cpp). It also means a second GPU would break it.

Solium’s own EGLDisplay (crates/solium/src/tty.rs, open_gpu) is built as EGLDisplay::new(gbm.clone()) — a GBM-platform display on a specific GbmDevice object, not eglGetDisplay(EGL_DEFAULT_DISPLAY). The probe imports the same dmabuf against both, to make the contrast impossible to miss:

dmabuf[default display]: eglCreateImageKHR failed 0x3009  (format=ARGB8888 stride=1024 modifier=0x300000000e08014)
dmabuf[gbm-paired display]: ok  texture=1 stride=1024 modifier=0x300000000e08014

Same fd, same 256×256 ARGB8888 buffer, same stride (1024), same modifier (0x300000000e08014 — vendor byte 0x03 is DRM_FORMAT_MOD_VENDOR_NVIDIA, a real tiled modifier, not DRM_FORMAT_MOD_INVALID). The default display rejects it with EGL_BAD_MATCH (0x3009); a display obtained via eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_KHR, gbm, nullptr) on the buffer’s own gbm_device* accepts it. Reproducible across three runs, both offscreen and eglfs, identical every time. EGL_EXT_image_dma_buf_import and EGL_EXT_image_dma_buf_import_modifiers are both present on both displays, and EGL_ANDROID_native_fence_sync is present — none of those were the blocker.

That run: Qt qt6-qtbase-6.11.1-1.fc44.x86_64 at run time (what the probe binary loads when run natively; it was built in the container against Qt6Gui 6.11.2 via pkg-config, the same 6.11 ABI line), on an NVIDIA GPU with NVIDIA’s proprietary driver.

Not tested by this probe: rendering into the bound texture through an FBO from a second, Qt-owned context on the same device, and fencing the hand-off to the compositor’s context — the harder half the spike named (“NVIDIA’s driver is where dmabuf round-trips and cross-context fences are least forgiving”). This result is the allocate/import/bind step only, and it works; the render-and-fence pipeline built on it is exercised end to end by dev/wirecheck (see its README). The two dmabuf[...] lines quoted above are the probe’s own output, from that run.

Keeping the nested window out of the way

Developing a compositor means running it as a window on the machine you are using. That window should not decide where it goes or take focus from whatever is already running.

Solium’s nested window sets app_id = solium-nested for exactly this, so the host compositor’s own rules can place it. On KDE (KWin 6) the rule below puts it on a chosen monitor, sets focus-stealing prevention to Extreme, so it does not take focus on its own, and passes keyboard shortcuts through to it while it is active. It is focused when you activate it — a click, Alt+Tab, the task bar — or when nothing else is active; see the last section.

~/.config/kwinrulesrc:

[<uuid>]
Description=Solium nested — opens on one monitor, movable, no focus steal, shortcuts passed through
wmclass=solium-nested
wmclassmatch=2
wmclasscomplete=false
position=<x>,<y>
positionrule=3
fsplevel=4
fsplevelrule=2
disableglobalshortcuts=true
disableglobalshortcutsrule=2

wmclassmatch=2 is a substring match, so it also matches any app_id that merely contains solium-nested; 1 is an exact match.

position is in the global layout, so the coordinates select the monitor: replace <x>,<y> with a point inside the target output’s geometry, as reported by kscreen-doctor -o. Apply without logging out:

qdbus-qt6 org.kde.KWin /KWin reconfigure

(qdbus-qt6 is Fedora’s name for the tool; Arch calls it qdbus6.)

positionrule=3, not 2

This one matters and is not obvious. KWin’s rule types are numbers:

ValueMeaning
2Force — KWin keeps the window there, permanently
3Apply Initially — KWin places it there once, then leaves it alone

Force is the wrong one, and it fails in a way that reads as a compositor bug: the window opens where you asked and then cannot be dragged at all, because every move is immediately overridden. Nothing logs, nothing errors, the titlebar just does not work. Use 3 — the point of the rule is to decide where the window opens, not to nail it down.

Solium logs which host monitor it landed on, so the rule can be checked rather than assumed:

INFO solium::winit: nested window is on this host monitor monitor="<connector>" x=<x> y=<y>

x and y are that host monitor’s own origin, not the window’s position, and in physical pixels, so on a scaled monitor they will not match the logical geometry kscreen-doctor prints. The monitor is reported a few frames in, not at startup — a Wayland client learns its output only when the host sends wl_surface.enter — and only once, so the line says where the window opened, not where it was dragged afterwards. A window across two monitors names one of them.

Shortcuts

Nearly every default binding is on Super (super+return, super+1 to super+9, super+shift+r and the rest), and a desktop such as Plasma has global shortcuts of its own on Meta, which the host takes first. Without the disableglobalshortcuts lines a key pressed in the nested window may never reach Solium. Scripted tests do not notice, because SOLIUM_TRIGGER_AT fires bindings inside the compositor and never goes through the host; only testing by hand does. Force (2) is right for this rule, unlike the position rule: KWin suspends its global shortcuts only while the nested window is the active one.

What focus prevention does and does not cover

fsplevel=4 stops the nested window taking focus from whatever you are using. It does not guarantee the window never receives focus: KWin allows activation when there is no other active window, since then there is nothing to steal from. So a run started while nothing is focused will show

INFO solium::winit: nested window focus changed focused=true

and that is the rule working as designed, not failing.

Rust internals

The Rust source’s own documentation, built by cargo doc --no-deps --document-private-items, so private items are included: this is for working on Solium, not for linking against it. Each summary is the first paragraph of that crate’s own documentation.

CrateKindWhat it is
soliumprogramSolium — the Wayland compositor of Lilium DE.
solium_animationlibrarySolium’s animation engine.
solium-animation: previewprogramBuild the animation preview page.
solium_effectslibrarySolium’s vertex deformations: the shapes a rectangle cannot hold.
solium-effects: effects_previewprogramBuild the effects preview page.
solium_layoutlibrarySolium’s window arrangements.
wl_probeprogramA Wayland client that asks the compositor questions and prints the answers.

Architecture is the design these implement, and Contributing how to change them.

Third-party code, behaviour and dependencies

Solium is published under the GNU General Public License, version 3 only. This file lists what in it comes from somewhere else: code adapted from another project, behaviour modelled on another project without its code, and the licences of what Solium links.

Code adapted from other projects

WhereFromLicence
fit in crates/layout/src/scroller.rsniri’s compute_new_view_offset, src/layout/scrolling.rs at niri-wm/niri@97c96a1, © the niri contributorsGPL-3.0-or-later
texture_mat in crates/solium/src/clip.rssmithay’s build_texture_mat and y_inverted flip, src/backend/renderer/gles/mod.rs in smithay 0.7.0, © 2017 Victor Berger and Victoria BrekenfeldMIT

GPL-3.0-or-later code may be used in a GPL-3.0-only work, and the file’s own header says which passage it is. MIT code may be used in a GPL-3.0-only work with its notice kept, and the notice is kept beside the function.

Behaviour modelled on other projects, with no code from them

These reimplement how something behaves. The behaviour is the specification; no code was copied beyond the passage listed above, and so no licence of theirs applies here. They are credited because the ideas are theirs.

WhereBehaviour ofTheir licence
crates/solium/lua/scrolling.lua, and the rest of crates/layout/src/scroller.rsniri’s scrolling layout: columns, and a view anchored to the focused oneGPL-3.0-or-later
crates/solium/lua/tiling.lua, crates/layout/src/tree.rsHyprland’s dwindle layout (CDwindleAlgorithm)BSD-3-Clause

Other mentions of niri, Hyprland, Quickshell and sway in the source are comparisons (“what niri does”) or links to how they behave, not code.

Rust dependencies

Everything in Cargo.lock, summarised by licence. The compositor’s own foundation is Smithay (MIT), and the Wayland, event-loop and hardware crates it builds on (the wayland-* crates, calloop, drm, gbm, input, libseat, udev, xkbcommon, xcursor) are all MIT too. Lua comes from mlua (MIT), which compiles the Lua sources vendored by lua-src (MIT). The session bus is spoken through zbus and the zvariant crates (MIT), on the async-io family of crates (MIT OR Apache-2.0). The signals that end a session are caught through signal-hook-registry (MIT OR Apache-2.0), which that family already uses.

LicenceCrates
MIT OR Apache-2.0 (in any of its spellings)161
MIT92
Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT (rustix, linux-raw-sys, io-lifetimes, wasip2, wit-bindgen)8
Apache-2.0 (winit, cgmath, approx, gl_generator, khronos_api, gethostname)6
Other choices that include MIT: with Unlicense, Zlib, BSD-2-Clause or BSD-3-Clause15
ISC (libloading)1
(Apache-2.0 OR MIT) AND BSD-3-Clause (encoding_rs)1
(MIT OR Apache-2.0) AND Unicode-3.0 (unicode-ident)1
Apache-2.0 AND MIT (dpi)1
MIT OR Apache-2.0 OR LGPL-2.1-or-later (r-efi, two versions)2

That is all 288 crates the lock file names. None is GPL- or AGPL-only, and none has an unknown licence. The one entry that mentions a GPL-family licence, r-efi, offers MIT and Apache-2.0 as alternatives, and is a dependency of getrandom only on UEFI targets, so no Linux build compiles it.

dev/wirecheck has its own lock file. Its 109 crates are a subset of the same families, with the same single r-efi entry.

To check this again after a dependency change:

cargo metadata --format-version 1 --locked \
  | jq -r '.packages[] | select(.source != null) | .license // "UNKNOWN"' \
  | sort | uniq -c | sort -rn

System libraries

Linked at build time and found on the system, never bundled: Qt 6 (Core, Gui, Qml and Quick, and the modules they require; LGPL-3.0, or GPL), Wayland (MIT), libinput (MIT), libseat (MIT), libxkbcommon (MIT), Mesa’s GBM and EGL (MIT), libdrm (MIT) and systemd’s libudev (LGPL-2.1-or-later). Each is used through its public interface and is compatible with GPL-3.0. With SOLIUM_PACING set, NVIDIA’s management library, libnvidia-ml.so.1, is opened at run time if the driver installed it, to read the GPU’s clocks; it is never linked or shipped.

Solium brand assets

This folder holds logo.svg and logotype.svg, and the default wallpaper crates/solium/qml/wallpaper/solium.png.

© 2026 Illia Kotomin.

These images are licensed under the Creative Commons Attribution-ShareAlike 4.0 International licence (CC-BY-SA-4.0), so distributions can ship them with Solium. That licence covers the image files. Solium’s code is under the GPL-3.0.

Attribute them as “Solium artwork © Illia Kotomin, CC BY-SA 4.0”.

The name “Solium” and the Solium logo identify this project. You’re welcome to use them to refer to Solium, for example in articles, packages of Solium, and links to it.

A fork or derivative that is distributed as a different project should use its own name and logo, and shouldn’t suggest it is Solium or endorsed by it. The CC BY-SA licence above gives permission to copy the images; it doesn’t grant any right to the name.

Where it stands

Solium is alpha. It runs on hardware and is being readied for daily use, but the trial on real hardware that decides whether it is ready has not happened yet, and it is not yet anyone’s daily desktop. What is being worked on, and in what order, lives on GitHub rather than in these pages, because it changes daily:

  • The daily-drive label is the list of what matters most: the issues that stop an application being used at all, first.
  • The project board shows which of them are being worked on, waiting for review, or done.

It has been checked on the hardware at commit dfc95ce, on a desktop with an NVIDIA RTX 3070 and on a Microsoft Surface Pro 7 (Intel Ice Lake), both on Fedora 44. QML on the GPU, animations, the screens going off when idle, swaylock, and the Caps Lock and layout pill all work there. On the Surface Pro 7 the screen also went off when the lid closed, though Solium has no lid handling of its own. Touch reaches applications, but not what Solium draws (#181).

The first release, v0.1.0, will be a preview release, cut only once the desktop is daily-drivable (the daily-drive label), the native preview shell is written (a bar at the bottom, a dock at the top, quick search, desktop icons, widgets with real data and a native lock screen), and there are packages: the point where it is ready for an open beta. The road to a public preview is that road.

The pages in this section are the longer view:

Roadmap

Epics in dependency order. Each should leave the compositor running and testable.

E1 to E5 have landed, and E6 closed as “modes as scripts”. The compositor boots on hardware, transforms and animates windows through one engine, is scripted in Lua, has floating, tiling and scrolling layouts, draws its own decorations from QML, and expresses every mode as a script over the transform — which was the bet, and it held: no mode has code of its own in the compositor. Overview needed no new Rust at all. Tiling and scrolling choose among arrangements that are Rust, in crates/layout; the Lua decides which, and when.

E7 to E11 are open. So is the work that turns a working compositor into a usable one. The daily-drive label is that list, prioritised by whether an application can be used at all without the thing, and What is left below says why the order is what it is.

For what has to be true before anyone else can run this, and the order to do it in, see beta.md.

Each epic below keeps its original acceptance criteria, because what “done” meant at the time is the more useful record.

E1 — Boots and shows a window

Smithay skeleton with a Vulkan renderer — the plan at the time; it shipped on GLES instead, for the reasons in docs/spikes/2026-08-27-vulkan-on-smithay.md. Winit backend for development, DRM for real hardware. One xdg-shell client rendered on screen, keyboard and pointer input working, clean shutdown.

Done when: a terminal opens in Solium under a nested session and accepts typing.

E2 — Presentation transform and animation clock

The core abstraction. Every window gains a target rect, opacity, radius and z-order used instead of real geometry, with one animation clock ticked from the render loop. Input hit-testing follows the transform.

Done when: a hardcoded call scales a window to half size in a corner, animates smoothly, and clicking it still focuses it. Layout is unchanged on reset.

E3 — Lua scripting surface

Embed Lua. Expose enumeration (windows, monitors, cursor), transforms (present, present_clear, animate), and binding (on, grab_input).

Done when: overview mode exists as a script only, no Rust changes. A key scales every window onto a grid, a second press restores them, clicking a thumbnail focuses that window.

This epic is the architecture’s proof. If overview needs Rust, E2 is incomplete.

Done. lua/overview.lua is overview; src/mode.rs was deleted rather than wrapped. Leaving restores the layout to the pixel — measured, not asserted.

E4 — Layout engine: floating, tiling, scrolling

Three layouts behind one interface, switchable at runtime. Scrolling follows niri’s model: an infinite horizontal strip with a viewport.

Done when: all three are usable for real work and switching between them animates through the transform layer rather than snapping.

E5 — Decorations, hosted in-compositor

Window frames drawn by the compositor, reserving their own space so the client shrinks and frame plus window read as one object. Titlebar, buttons, shadows, rounding.

Done when: windows have working titlebars whose buttons respond, and the client area is never covered.

E6 — Modes as scripts

App switcher, peek-on-hover, and the icon→window genie — each a script over E2, each shipping with the compositor as a default that users can replace.

The genie needs a dock icon rect. That rect arrives with the dock’s frame commit, never on a side channel.

Done when: all three work, and none required new Rust.

Closed as “modes as scripts”, which is what shipped: overview, tiling and scrolling are scripts, and modes.lua decides which one is in charge. The app switcher and peek were never written. The genie is an effect, but its only binding, super+m, is a development one aimed at a fixed rectangle, because there is no dock icon to aim at. The rule about the dock’s rectangle was written for a dock outside the compositor’s engine. A dock hosted in it would name its icon from QML, which is planned and not built; only a dock that runs as its own program would need a protocol. See tier 3.

E7 — Touch, gestures, form factors

Touch input, gesture recognition, and per-form-factor input profiles and mode defaults. niri is the reference for touch on Smithay.

Done when: the same binary is usable by touch on a tablet and by pointer on a desktop, with no code fork.

Status. Open. Touch reaches applications’ windows and a tap focuses the window under it (seen on a Surface Pro 7), but nothing the compositor draws (frame buttons, a hosted shell, the overview, window edges) reacts to touch, and scripts hear none (#181). Gesture recognition is still this epic and comes after v0.1.0.

E8 — Settings

Settings surface reading tunables declared by scripts rather than a fixed compositor schema, so adding a mode adds its settings automatically.

Done when: changing a setting is visible immediately, with no restart.

E9 — The transform layer and the layouts, made right

Three places hold a window’s position and nothing makes them agree. Filed after the same shape of bug arrived three times from hardware. Includes the resize problems, and a spike reading how Hyprland and niri each solved resize.

Done when there is one answer to “where is this window”, and a mode that gets it wrong fails loudly rather than quietly.

Status. Open. What has landed: one function, Solium::move_pane, makes the three copies agree whenever a pane is placed; during a resize the pane’s slot, not the client’s size, is the authority (#113, #120, #123, #124); every hit test asks one question, whether a pane owns the point on a screen that draws it (#134); and the layouts survive a reload (#118, #129). What is left is the done-when itself: showing that every path goes through that one answer, and making a wrong one fail loudly. No spike on how Hyprland and niri solved resize is in the repository.

E10 — Atrium, and what modes still cannot do

A stage-manager mode, named for the central hall of a Roman house rather than for Apple’s word. The point is not the mode: it is the strongest test the architecture has been given, and whatever it turns out to need in Rust is exactly the list of what the transform layer still lacks.

Done when atrium is a script somebody can rewrite, and nothing in it needed a special case in render.rs.

Status. Open, and atrium is not written. Three of the pieces #85 lists have landed: groups (sol.group, sol.present_group), stacking from a script (z in sol.present), and an opacity in the transform. Paths, an input grab scoped to part of the screen, and blur behind a window (#78) have not.

E11 — A shell hosted inside Solium is a complete daily desktop

A shell runs inside Solium as its configuration: its QML is hosted in the compositor’s own engine, beside the decorations, rather than run as a program of its own. What that still needs was listed by loading a real shell, written for Quickshell, in a nested session.

Done when a shell hosted this way is the whole desktop: on every monitor, reserving its edges, taking the input its items ask for, and driving the windows and workspaces it shows.

Status. Open. Milestone 1 of the native shell platform has landed: one instance per monitor, each reading Solium.monitor (#161); input only where its items take it (#173); every button, the wheel, Grab popups and the keyboard on demand (#163); edges reserved whatever the scene’s size (#162); named actions with data, sent with Solium.send and done by sol.act; and live Windows, Workspaces and Monitors models (#166), the workspaces as workspaces.lua declares them with sol.workspaces. Separately, a hosted shell’s timers fire on an idle desktop (#164), and a program it starts, through Solium.send, Lua and sol.spawn, gets the environment Solium started with (#175). The Quickshell compatibility layer was removed (#172), and #165, about the programs that layer’s Process started, was closed as superseded by it. The epic still holds #167, the services a shell shows (audio, notifications, media, battery, network, Bluetooth, the tray and the list of applications), which was written against the layer that was removed; and, for later, the effects work: a hosted dock naming its icons with Solium.region for the genie, and widgets that morph into the lock screen.


What is left to be a complete compositor

The epics above describe the architecture, and most of it holds: one engine, one clock, modes as scripts, decorations in-process. E9 and E10 are the parts of it still open. A compositor is complete when a person can use it all day without meeting something it cannot do, and that is a different list — protocols, session plumbing and missing keys, mostly unglamorous, and each one invisible until the day it is missing.

Three tiers, and the order matters more than the contents. They are the argument for the order, not a status board: the live list is the daily-drive label, and a row here is an example of its tier. docs/gaps.md is the exhaustive list — every protocol not implemented and every non-protocol gap, whether or not it is worth doing soon.

1. Things a desktop cannot do without

These are not features. Each one is a day where somebody stops using the compositor and does not come back.

why it stops someone
#26 IMEno CJK, no emoji picker, no on-screen keyboard. Unusable for most of the world’s writers. compose:ralt gives a compose key meanwhile
#55 virtual-keyboardthe other half of an on-screen keyboard, which is what a phone is
#56 window rulesa script can read a window’s app_id, but there is no rules table. The first thing anybody configures
#63 multi-GPUa monitor on the second card cannot be driven at all — which is docking an ordinary laptop
#50 foreign-toplevela shell that runs as its own program cannot list windows or switch to them, so it cannot have a task switcher
#51 output-managementmonitors are arranged by the configuration and moved on a reload; no client can move one, so E8’s settings surface and kanshi cannot
#52 data-controlno clipboard manager can work

The last three were found by writing this section, which is the argument for writing it. The shell is the reason all three matter more here than elsewhere: a dock that cannot enumerate windows is a launcher, and a settings panel that cannot move a monitor is a text editor with buttons.

The session. Two gaps between the compositor and the rest of the user session, each a day lost; gaps.md has the detail.

why it stops someone
#153 logindloginctl lock-session does nothing, and nothing locks before sleep unless swayidle -w does
#157 libinput settingsno tap-to-click, so tapping a touchpad does nothing

2. Things that have to be true, not built

Correctness and confidence rather than capability. Nothing here adds a feature and all of it decides whether the thing is trustworthy.

#48 the seat flakethree of forty-three logged sessions got no input devices and were stopped by the watchdog; a longer watchdog would not have saved one, and the cause is not known
#65 a hardware soakthe compositor has never run unattended for hours on a real session; dev/soak.sh can now do it on a TTY, though only its clients churn there, since the TTY backend ignores the scripted key presses
#66 packagingdev/install.sh installs from a checkout and dev/rpm.sh builds a Fedora 44 package of one, but there are no packages in a repository, and a preview nobody can install is a preview nobody tries
#64 suspend and resumenever tested once. A laptop that cannot be closed and opened is not a laptop
#83 portalsscreen sharing is reasoning, not evidence: nothing has been run end to end

3. The part that is not parity

Everything above brings Solium level with a good tiling compositor. None of it is why this project exists.

  • E7 — touch, gestures, form factors. One binary usable by touch on a tablet and by pointer on a desktop. The animation engine already takes an initial velocity precisely so a gesture’s throw can be handed to it; nothing yet hands it one. Touch already reaches applications, but not what the compositor draws (#181), and gestures come after v0.1.0.
  • E8 — settings. A surface built from what scripts declare rather than a fixed schema, so adding a mode adds its settings.
  • The dock, and the morph. sol.present_from still does what docs/shell-boundary.md records: a window grows out of the rectangle an icon occupied. What is missing is a dock icon to aim at. For a dock hosted in the compositor that is a question inside one engine, not a protocol: the plan is for its QML to name the icon so an animation can follow it. A dock that runs as its own program would need a protocol to hand the rectangle over, and doing that without a side channel is still open.

What “complete” would mean here

Not “has every protocol”. A compositor is complete for this project when the same binary runs a phone, a tablet and a desktop; when every mode is a script somebody can rewrite; and when nothing in a normal day makes the user notice they are running something unusual. The first is E7. The second is done. The third is tier one.

Honest position on evidence. Most of tiers one and two is judged from nested runs and from reading. Some has been confirmed on the hardware: hotplug, popups and menus, and the drag icon; and, at dfc95ce on an NVIDIA RTX 3070 desktop and a Surface Pro 7 (Intel Ice Lake), QML on the GPU with animations running, the screens going off when idle, swaylock, and the Caps Lock and layout pill. On the Surface Pro 7 the screen also went off when the lid closed, though Solium has no lid handling of its own. The hardware backend brings up more than one monitor, picks modes and CRTCs, and has been used with real applications on a TTY — but no soak, no leak measurement and no screencopy test has ever run on it. That is not a small caveat and it belongs in the plan rather than in a footnote.


Sequencing notes

  • E2 and E3 are the whole bet. If overview cannot be a script, the architecture is wrong and it is cheap to find out early.
  • E5 comes after E3 deliberately. Decorations were what the Hyprland fork attempted first, and they turned into a plumbing project that never reached the interesting work.
  • E7 is late but must not be an afterthought. Design the input layer in E1 so touch is a profile, not a retrofit.

Everything not built yet

The roadmap says why things come in the order they do. This says what there is to do — the whole list, so that nothing is missing because nobody thought to look for it. What to do first is the daily-drive label. This page carries no status: a row leaves when its gap is closed.

Written by asking the compositor rather than by remembering: the “have” list below is wayland-info against a running Solium, and the “missing” list is every protocol in wayland-protocols, wayland-protocols-wlr and wayland-protocols-misc that is not in it. Twenty-seven globals in, about forty out; the list below is the count.

Everything here is on the tracker: writing this page found thirty-one gaps that were not, and they were filed as #53–#83.

A shell can run inside Solium, hosted in its QML engine, or as a program of its own over wlr-layer-shell (see shell-boundary.md). Where a row below says a shell cannot do something, it means the second kind unless it says otherwise.

What is there today

wl_compositor · wl_subcompositor · wl_shm · wl_seat · wl_output · wl_data_device_manager · xdg_wm_base · zxdg_decoration_manager_v1 · zxdg_output_manager_v1 · xdg_activation_v1 · zwlr_layer_shell_v1 · zwlr_screencopy_manager_v1 · ext_session_lock_manager_v1 · ext_idle_notifier_v1 · zwp_idle_inhibit_manager_v1 · zwlr_output_power_manager_v1 · wp_presentation · wp_viewporter · wp_fractional_scale_manager_v1 · zwp_linux_dmabuf_v1 · xdg_wm_dialog_v1 · wp_single_pixel_buffer_manager_v1 · zwp_relative_pointer_manager_v1 · zwp_pointer_constraints_v1 · zwp_primary_selection_device_manager_v1 · wp_cursor_shape_manager_v1 · zwp_text_input_manager_v3, plus xwayland_shell_v1 to the X server only.


1. Blocks a normal day

Each of these is a day where somebody stops using the compositor.

what breaks without it
#26 input-method-v2no CJK, no emoji picker, no on-screen keyboard — and the on-screen keyboard is what a phone is. text-input-v3 is answered, but only to learn where the focused field’s caret is: no text goes back to it. A compose key does work meanwhile: keyboard = { options = "compose:ralt" }
#50 ext-foreign-toplevel-lista shell that is a program of its own cannot list windows, so it cannot have a task switcher; switching to one needs zwlr_foreign_toplevel_management_v1 as well
#51 wlr-output-managementmonitors are arranged by the configuration: sol.monitors places and scales them, and super+shift+r applies a change. No client can do it, so kanshi and a settings panel cannot work, and a monitor’s mode and rotation are read only when it is added
#52 data-controlno clipboard manager can work
#55 zwp_virtual_keyboard_v1the other half of an on-screen keyboard. input-method-v2 says what was typed; this is how anything types it
#56 window rulesa script can read a window’s app_id, and tiling.client_size_ignore matches on it, but there is no way to say “this application starts on that workspace, floating, this size”. Not a protocol — a gap in the scripting surface, and the first thing anyone configures

2. An application will hit it

Not a whole day lost — one application, or one workflow, that does not work.

who needs it
#58 keyboard-shortcuts-inhibita VM, a remote desktop, or a terminal multiplexer that wants Super for itself. Without it, those keys are eaten forever
#59 linux-drm-syncobj (explicit sync)modern Vulkan and NVIDIA clients. Its absence is stutter and the occasional torn frame, and it is the single most-reported “your compositor is broken” on other projects
#67 xdg-foreign-v2a file chooser or a screen-share dialog that has to be parented to the window that opened it. Otherwise it lands wherever the layout puts it
#68 wp-security-context-v1how a Flatpak identifies itself. Without it there is no way to treat sandboxed clients differently, ever
#60 ext-workspace-v1Solium has workspaces, in workspaces.lua, and no client can see or switch them. A shell that is a client cannot show which one you are on; a hosted shell can, through Workspaces (#166)
#69 zwlr_gamma_control_v1night light. gammastep and redshift speak only this
#61 KDE server-decorationsome Qt applications ask for decorations with this and nothing else, and draw none when it is missing
#62 zwp_tablet_manager_v2drawing tablets, and the stylus on any 2-in-1 — which is a form factor this project is explicitly for
#70 wp_pointer_gesturestouchpad pinch and swipe reaching clients. A browser cannot pinch-zoom
#37 tearing-control-v1a game that wants to opt out of vsync
#71 wp_content_type_v1lets a surface say “I am video” so VRR and tearing decisions can be made for it rather than guessed
#47 ext-image-copy-capture-v1the successor to wlr-screencopy. Nothing speaks it here yet; everything will
#73 xdg_toplevel_icon_v1window icons — which a task switcher needs and cannot get any other way
#75 wp_alpha_modifier_v1per-window opacity without asking the client to redraw. Free, given the render path already scales and warps

3. Later, or for the shape this project is going

#76 wp_fifo_v1 + wp_commit_timing_v1the modern frame-pacing pair. Solium’s own pacing is good; this is how clients participate in it
#77 wp_color_management_v1HDR, and the ceiling on #46 10-bit output
#78 ext_background_effect_v1blur behind a surface. For a compositor whose whole argument is that it looks like one thing, this is more interesting than it sounds
#79 the long tailxdg-session-management, wlr-virtual-pointer, drm-lease, ext-transient-seat, xdg-toplevel-tag, pointer-warp, color-representation. One issue rather than seven, because none of them has a client or a story yet. Split one out the moment something real needs it

Deliberately not, with reasons in #79: wl_drm, zwlr_input_inhibit_v1, fullscreen-shell, linux-explicit-synchronization, zwlr_export_dmabuf_v1, input-timestamps.


4. Not protocols

The session

#153 logindthe Lock and PrepareForSleep signals are ignored, so loginctl lock-session and whatever locks that way do nothing, and locking before suspend is up to swayidle -w
#151 media keysno volume, brightness, media, screenshot or lock keys are bound by default
#157 libinput device settingsnone are set: no tap-to-click, which libinput leaves off, so tapping a touchpad does nothing; no acceleration profile or speed, disable-while-typing, left-handed mode or middle-button emulation; and natural scrolling comes only from the form factor, for every device at once

The backend

#63 multi-GPUudev::primary_gpu and nothing else. A laptop with a discrete card renders on one of them, and a monitor on the other card’s port cannot be driven at all
#44 monitor identitya screen is matched by which port it is in, so moving a cable moves the configuration
#45 mirroringno way to put one screen on another. Every presentation wants it
#46 10-bit
#80 runtime rotationtransform is read from the configuration when a screen is added; a tablet cannot rotate when it is turned
#42 absolute devicesa touchscreen is glued to the first monitor
#64 suspend and resumenever tested. logind pauses and resumes the session’s devices; whether Solium comes back is unknown

Correctness and confidence

#48 the seat flakethree of forty-three logged sessions got no input devices. Devices arrive within a few seconds or not at all, so a longer watchdog would not have saved one; the cause is not known
#65 a hardware soakthe compositor has never run unattended for hours on a real session. SOLIUM_SOAK_TTY=1 dev/soak.sh can do it, though only its clients churn there, since the TTY backend ignores the scripted key presses; no run is recorded
#40 xwayland selection flusha workaround waiting on Smithay
#180 an X11 window’s launcha window exists from the keypress that launches it, as a loading window until its application has drawn, but an X11 application’s window never replaces it, so the loading window stays on screen
#66 packagingdev/install.sh installs from a checkout and dev/rpm.sh builds a Fedora 44 package of one, but there are no packages in a repository: no COPR, no Arch PKGBUILD. --config (#106) and the configuration directory’s name (#107) are worth settling first
#82 crash recoverythe compositor dying takes the session with it. There is no supervisor and nothing to come back to

The product

E7 touch, gestures, form factorsthe animation engine takes an initial velocity precisely so a gesture’s throw can be handed to it. Nothing yet hands it one, and gestures come after v0.1.0
#181 touch on the compositor’s own UItouch reaches applications’ windows and a tap focuses the window under it, but nothing the compositor draws reacts to a finger (frame buttons, a hosted shell scene, the overview, window edges), because touch goes only to client surfaces; a script hears no touch either
E8 settingsa surface built from what scripts declare rather than a fixed schema
the dock, and the morphsol.present_from already grows a window out of the rectangle an icon occupied, and a genie can aim at a window or a scripted surface. What is missing is a dock icon to aim at. A dock hosted in the compositor is in the same engine, and the plan is for its QML to name the icon so an animation can follow it while it moves; nothing of that is built, and it is tracked under Later in #169. Only a dock that runs as its own program would need a protocol to hand the rectangle over
#30 a minimise stateso the genie animation means something
#83 portalsxdg-desktop-portal-wlr can screen-share through wlr-screencopy, but nothing has been configured or tested end to end, and file chooser and settings portals are separate again

What this list is not

It is not a plan, and length is not weight: wp_alpha_modifier_v1 is close to free, since the render path already carries an opacity, while #153 is the difference between a laptop that locks before it sleeps and one that wakes up unlocked. The label orders these and the roadmap says why; this only makes sure none of them is forgotten.

The road to a public preview

Written 2026-09-07. Revised 2026-09-18, after the first day of using it for real work rather than developing it, and again 2026-09-30, and again 2026-10-04. Where the compositor stands, what has to be true before strangers run it, and the order to do it in.

The first version gated on protocols and platforms — multi-monitor, HiDPI, screencopy, session lock — and all of it is done. The 2026-09-18 revision found instead that the three things Solium is actually for each had something in them that made the experience worse than the architecture deserved. Most of that is fixed now, and is listed under Done since this was written. What blocks a preview today is the native preview shell, which is not written yet, and otherwise mostly where the compositor meets the rest of a user session.

Where it stands

E1 to E5 of the roadmap have landed, and E6 closed as “modes as scripts”: it boots on hardware, transforms and animates every window through one engine, is scripted in Lua, has floating, tiling and scrolling layouts, draws its own decorations from QML, and expresses every mode as a script over the transform.

That last one was the bet. If overview had needed new Rust the architecture was wrong and everything after would have been fighting it. It didn’t — overview is about a hundred lines of Lua.

It has run real applications: Firefox, LibreOffice, Steam, Konsole, Dolphin, Okular, X11 clients through XWayland, with no protocol errors. Copy and paste works in both directions across the X11 boundary. It is not yet anyone’s daily desktop: real use is the only test that counts for a compositor, and the trial on real hardware that decides whether it is ready has not happened yet.

The protocols it speaks, and every one it does not, are listed in gaps.md.

A shell — the bar, the dock, the launcher — is a project of its own. It runs inside Solium, hosted in the compositor’s QML engine as part of the configuration, or beside it as an ordinary wlr-layer-shell client; shell-boundary.md draws the line.

Call it a preview, not a beta

“Beta” tells people it is nearly ready for normal use. It is not, and a compositor crash takes the session and whatever was unsaved in it. Spending that trust once is enough to lose it.

A public preview, aimed at people who want to hack on a compositor rather than people looking for a desktop, is honest and achievable. It also gets the first-contact bugs sooner and smaller, from people who will not be angry about them. Nobody but the author has run this yet, on two machines: a desktop with an NVIDIA RTX 3070 and a Microsoft Surface Pro 7 (Intel Ice Lake graphics), both Fedora 44. First contact with other GPUs and drivers and other people’s configurations will produce a burst of bugs that no amount of local testing predicts.

What still blocks

The public preview is v0.1.0, a preview release. It is cut only once the desktop is daily-drivable (the issues labelled daily-drive), the native preview shell exists (a bar at the bottom, a dock at the top, quick search, desktop icons, widgets with real data and a native lock screen), and there are packages: the point where it is ready for an open beta. It is not cut until that is done. The native preview shell is not built yet: the shipped configuration hosts no shell.

Blocking, by which of Solium’s own ideas it breaks. A preview exists to show what a compositor is for. Solium’s three claims are that its layouts are scripts, that its chrome is QML and belongs to one design system, and that you can change either while it runs. One of the three, the chrome (#102), still has something in it that undermines the claim. The other two are clear.

The layouts are the pitch

Nothing blocks here any more. A layout’s pass leaves a fullscreen or maximised window where it is, so a window opening no longer puts a fullscreen video back into its tile; see below.

The chrome is the differentiator

why
#102 no decoration policya GTK dialog gets Solium’s titlebar and keeps its own rounded body underneath. Two decorations on one window, and no way to say “this one draws its own”. The cost #103 knowingly accepted, and this is what settles it

Changing it while it runs

Nothing blocks here any more. A reload keeps the session, user.lua can add a binding, and solium --check says when a setting is read by nothing; see below. “Edit a file, press a key, watch it change” is the sentence a preview is sold on, and it is now true.

Anywhere but here

why
#66 packagesa preview nobody can install is a preview nobody tries. Done: an installed binary finds its own QML and Lua, and dev/install.sh installs from a checkout into ~/.local on Fedora 44 and prints the one sudo line that puts the session file where the login screen reads it (dev/install-check.sh checks it). dev/rpm.sh builds a Fedora 44 package of a checkout, a development snapshot that dnf installs under /usr. Left: packages in a repository — COPR, an Arch PKGBUILD — plus --config (#106) and the configuration directory’s name (#107), which are worth settling before the recipes are written
#65 a soakthe compositor has never been left running unattended for hours, with window churn, on a real session. SOLIUM_SOAK_TTY=1 dev/soak.sh can do it now; what is missing is the run. A preview that dies after six hours is worse than one that is missing a feature
#64 suspend and resumenever tried at all, which is the same sentence about laptops

Done since this was written

#161, #173, #163, #162 a hosted shellone instance per monitor, each reading Solium.monitor; clickable only where its items take input; every button, the wheel and the modifiers; popups that hold the pointer with Grab; the keyboard when an item asks with Solium.keyboard; an edge reserved whatever the scene’s size; and, for #166, live Windows, Workspaces and Monitors models
#164 a hosted shell’s timersfire on an idle desktop, on the compositor’s clock
#174 Escapereaches applications; the overview binds it only while it is up
#175 what a program inheritsprograms Solium starts get the environment Solium started with and no descriptor beyond stdio
#177 relaunchingrelaunching a running single-instance application brings its window forward where it is; sol.on("activate") tells the scripts
#178 the caret and the keyboard as datatext-input-v3 caret positions, sol.text_input(), sol.on("keyboard") and sol.on("text_input"), caps and num in sol.keyboard(), the Keyboard QML singleton, sol.pane_values, and a Caps Lock and layout pill written in Lua and QML only, set with keyboard.indicator
#150 fullscreen kepta layout’s pass leaves a fullscreen or maximised window where it is, and leaving fullscreen goes back into the tile the layout gave last
#113 resizea resize is the rectangle you drag, tiled or floating: while an edge is held the pane’s slot is the authority, not the client’s size, so the edge you grab is the one that moves (with #120, #123 and #124)
#116, #118, #129 reloadsuper+shift+r keeps the session: workspaces, the mode in charge, and every tiling tree and scrolling strip, desks not in view included. See modes.md
#117 the configuration says what it doesbindings = {} in user.lua adds, replaces or removes a binding; every setting config.lua advertises is read; and solium --check names a setting nothing reads, suggests the one you meant, and exits 1
#115, #134 sizeslayouts respect each application’s minimum and maximum size; a tile has a configurable minimum, and a new window that does not fit goes to the next empty workspace
#72 modal dialogsfloat over the window waiting on them, on its screen, X11 dialogs included
#92, #141, #142 fullscreenleaving fullscreen puts a window back where it was, and a fullscreen window covers the bars and takes their clicks unless fullscreen.covers = "none"
#126, #127 closinga window its own application closes fades out like one Solium closes, and a close that has started always finishes
#121, #132 keysbindings fire under a non-Latin layout, and a shifted binding on a non-letter key, super+shift+1 included, is reachable
#149 frame callbackslayer-shell and lock surfaces are told when to draw again, so a bar or a locker keeps drawing
#147 QML on the GPUthe default on the hardware, once a trial render in a child process has passed; software otherwise, and in nested sessions
#54 screens offwlr-output-power-management, sol.monitor_power, and the screens going off by themselves after idle.screens_off_after; and seen working on hardware, idle screen-off on an NVIDIA RTX 3070 desktop and a Surface Pro 7, with swaylock locking on both
the lock, hardenedonly the lock surface can have the keyboard, and locked is sent only once every monitor shows the lock
#53 keyboard layoutin config.keyboard, with the repeat rate — which was the part that genuinely could not be changed. The layout could always be set through XKB_DEFAULT_LAYOUT
#150 keyboard navigationsuper with the arrows or h j k l moves focus, and with shift moves the window; super+f is fullscreen, super+shift+m maximised and super+shift+space floating. The layout in charge decides what a direction means
#146 the sessionSolium tells systemd and D-Bus activation where the display is and starts graphical-session.target, so portals, autostart and programs started as user units find it
#152 D-Bus idle inhibitSolium owns org.freedesktop.ScreenSaver (idle.dbus_inhibit), so a browser’s request to keep the screens on holds them on like a Wayland inhibitor
#41 multi-monitora pipeline per monitor, one global space, layouts and workspaces per screen
#43 hotpluga monitor plugged in is picked up, one pulled out is let go, and its windows come back
#39 HiDPIscale per monitor, chrome rasterised at it, chosen from the panel’s dpi
#28 screen capturewlr-screencopy, so grim and wf-recorder work. The portal has not been tested end to end (#83)
#27 session lockext-session-lock-v1, and it fails locked rather than open
#33 the leakre-measured and not reproducible: dev/leak.sh over 40 windows in four settled cycles returns to within ±1 MB of baseline, with file descriptors two below it. The number in that issue’s title should not be quoted until a soak on real hardware says otherwise

Shippable as documented gaps. Real holes, but ones a preview can name and survive: #26 IME, #52 no clipboard manager, #83 portals never tested end to end, #56 window rules, #153 logind lock and sleep, #157 no tap-to-click, #181 touch on nothing the compositor draws, #180 an X11 application’s window never replacing its loading window. Each should be named where a stranger will look before they hit it, and docs/gaps.md is the full list.

Not features, and do them anyway. CI runs on Fedora 44 and gates stage and release: fmt, clippy, the build, the tests and solium --check. It still does not run the checks under dev/, and dev/gate.sh runs one CI does not, the QML GPU check. Two gates testing different things is one gate.

Order, and why

  1. The session: #153, so that loginctl lock-session locks and the screen is locked before the machine sleeps.
  2. #102, the decoration policy.
  3. Soak and suspend on hardware, #65 and #64. These are the difference between working here and working anywhere.
  4. The native preview shell: a bar at the bottom, a dock at the top, quick search, desktop icons, widgets with real data and a native lock screen, hosted in Solium.
  5. Packages, #66, once #106 and #107 are settled.
  6. Then the P3s, after the preview is out.

Priorities on the tracker are by whether an application can be used at all without the thing, not by effort. That is why wp_alpha_modifier_v1 (#75) is P3 — no application stops working without it — and multi-monitor was P1.

Notes from the work already done

  • #41 multi-monitor was the biggest and the one everything else is easier after, which is why it went first. Left over as their own issues: #42 absolute devices, #44 matching a monitor by what it is rather than which port it is in, #45 mirroring, #46 10-bit.
  • #39 HiDPI: #41 was indeed most of it, because every call site already took an output or a screen rect. The part that was not plumbing was the QML host, which needed the distinction between logical layout and device rasterisation rather than a bigger canvas — a scene given the device size lays out in it and comes out half the size it should be.
  • #28 screencopy was self-contained, as expected. Its successor, ext-image-copy-capture-v1, is #47: the wayland-protocols crate carries it, but Smithay 0.7 has no handler for it, so it would be written by hand, as screencopy.rs was.
  • #27 session lock: the protocol was the easy half. The hard half was that “locked” has to mean something to every path that interprets input, and one of them asked none of the questions the others did. The resize path walked the panes itself rather than going through the hit test, so with the obvious guards in place a drag on a locked screen still resized a window nobody could see, and it was still that size after unlocking. Found by firing a scripted drag during a lock and measuring the window afterwards, which is the only reason it was found at all. There is one hit test now, so there is one guard.
  • #36 idle: what the issue said would happen did. The visibility rule was written against where a window lives, and a workspace switch moves where it is drawn, so a video on another workspace went on holding the machine awake until a test hid one and waited.

The lessons that cost the most time on the way here are in CONTRIBUTING.md. The checks and tools that exist, with what each one asserts, are in dev/README.md.

Spike: Vulkan renderer on Smithay — which path?

Issue: #9 Date: 2026-08-27 Outcome: start on GLES2. Vulkan is a later, optional port.

Since then (2026-09-30). The outcome held: Solium draws with GLES2 through renderer_gl; Smithay 0.7, which it builds against, still has no Vulkan renderer; and niri still uses GLES. The constraint this spike set did not hold. The 3D-presentation spike crossed the trait line on purpose on 2026-09-06, in one file, and GLES has since spread past that file. So a Vulkan backend is no longer “a contained change”; What a Vulkan port costs now says what it would take. No issue schedules one. Two of the three reasons given below for wanting Vulkan do not need it: explicit sync (#59) is a protocol Smithay offers on GLES, and Smithay’s multi-GPU renderer (#63) is built on GLES. Compute shaders remain a reason. “Also found”, at the end, is resolved.

Question

Does Smithay support Vulkan rendering today, or does choosing Vulkan mean implementing the renderer ourselves?

Findings

Smithay has no Vulkan renderer

smithay::backend::renderer ships: GLES2 (renderer_gl), Pixman (software, renderer_pixman), Glow (renderer_glow), Multi-GPU (renderer_multi), and a test renderer. There is no Vulkan implementation.

smithay::backend::vulkan exists but does not render

It is easy to mistake this module for Vulkan support. It is device enumeration and instance initialisation only — Instance, PhysicalDevice, AppInfo, thin wrappers over ash. Its own documentation is explicit:

This module does not provide abstractions for logical devices, rendering or memory allocation. These should instead be provided in higher level abstractions.

So it helps you find a GPU. It does not help you draw with it.

niri uses GLES, not Vulkan

The reference implementation — Rust on Smithay, with working touch and gesture support, which is the main reason we chose this stack — enables renderer_gl, renderer_pixman and renderer_multi. No ash, no Vulkan anywhere in its dependencies.

What Vulkan would actually cost

A from-scratch renderer backend implementing, at minimum:

TraitFor
Renderer, Framethe drawing API itself
Bind, Offscreenrender targets, offscreen buffers
ImportDma, ImportDmaWlclient buffers via dmabuf
ImportMem, ImportMemWlshm client buffers
ExportMemscreenshots, screencopy
Blit, BlitFrameframebuffer blitting

ImportDma on NVIDIA is the risky one. Every GPU-rendering client hands us a dmabuf, and importing those into Vulkan images with correct format and modifier negotiation on the proprietary driver is exactly where this kind of work stalls — and it must work before a single window appears on screen.

Recommendation

Build on GLES2 via renderer_gl. Reasons, in order:

  1. It is what the one proven Rust/Smithay compositor with the form-factor support we want actually uses. Following a working reference is worth more than a nicer API on paper.
  2. It removes an unbounded task from the critical path. A renderer backend plus NVIDIA dmabuf import is not a prerequisite for anything we are trying to prove — E2 and E3 are the architectural bet, and they are about transforms and scripting, not about which GL API issues the draw calls.
  3. Smithay’s renderer traits are the abstraction boundary. If the transform layer is written against those traits rather than against GLES directly, swapping in a Vulkan backend later is a contained change.

Constraint that follows: E2 must not reach past the Renderer/Frame traits into GLES specifics. If it does, the Vulkan option closes quietly.

Correction to docs/architecture.md

The architecture document justified Vulkan by saying the transform-and-animate layer “wants explicit, modern GPU control”. That is weaker than it sounded. Scaling, translating and cross-fading window textures is unremarkable work that GLES2 does perfectly well. The honest reasons to want Vulkan later are compute shaders, explicit synchronisation, and better multi-GPU handling — none of which we need to build overview mode. The document has been corrected.

What a Vulkan port costs now

Added 2026-09-30. render.rs says “the cost is recorded in the spike”; this is that record. Each row is GLES or EGL that a Vulkan backend would have to replace, on top of the renderer traits in the table above.

WhatWhereWhy it is GLES
The render elementscrates/solium/src/render.rsthe element set is typed on GlesRenderer, because the warp element can only be GLES
Drawing through four corners (perspective, the genie)warp.rsraw GL through with_context: nothing on the traits places a texture’s four corners independently
Rounded cornerspass.rs, crates/effects/src/fragment.rsa GLSL ES 1.00 program compiled with compile_custom_texture_shader, which is GLES-only, drawn by a render element of its own
A closed window fading outremains.rsa render element of its own on GlesRenderer
QML on the GPU, the default since #147qml/host.cpp, qml/paint.rs, surface.rs, and the cursor and decoration scenesQt renders with OpenGL into a buffer the compositor allocated; the compositor then makes its own EGL context current again and waits on Qt’s EGL fence
Everything that draws or reads backcursor.rs, decoration.rs, offscreen.rs, screencopy.rs, tty.rs, winit.rseach takes a GlesRenderer

What would carry over: the transform (present.rs), the animation and layout crates, the effects crate’s vertex effects, and every script. QML’s software fallback draws into ordinary memory, so it needs only a new upload.

Also found

At the time, the machine this spike was written on had no Rust toolchain, neither on the host nor in the container then in use, and #10 waited on one. Resolved: Solium builds natively with a rustup toolchain, or in the project’s build image with Rust mounted in from the user’s own rustup (dev/README.md, Building), and #10 is closed.

Spike: hosting QML inside the compositor

Date: 2026-09-04 Outcome: works, via Qt’s software scene graph. The GPU path is blocked on Qt, not on us, and the block is recorded below so nobody re-derives it.

Since then (2026-09-30). This outcome was overtaken. Route 2 below, the dmabuf-backed render target, landed in #94 (2026-09-15), and since #147 (2026-09-29) QML renders on the GPU by default on the hardware, with this page’s software path as the fallback and as what a nested session uses. Context adoption is still impossible, so route 1 was never taken. How the renderer is chosen, and how to force one, is in dev/README.md, QML on the GPU. The top bar measured at the end was the compositor’s own and was removed on 2026-09-05; a bar now belongs to a shell, hosted or not (shell-boundary.md).

Question

The shell and window decorations are to be authored in QML. Out-of-process was already tried in the Hyprland fork and rejected — ~15 fps at 39% CPU after optimisation, with the process boundary as the ceiling. So: can Qt Quick render inside a Rust/Smithay compositor?

What works

Yes, in-process, with no IPC:

  • A small C++ shim (crates/solium/qml/host.cpp) behind a C ABI, compiled by build.rs. No moc step — nothing in it needs Q_OBJECT.
  • QQuickRenderControl + QQuickWindow, no visible window, offscreen QPA.
  • Animations driven by the compositor’s clock through a QAnimationDriver the render loop advances by hand. Not Qt’s timer: there is one clock in this compositor, and QML animating off a second one would drift against every window transform beside it.
  • No Qt event loop. Nothing calls exec(), so QCoreApplication::processEvents is pumped once per frame or queued work never runs.

What does not work: rendering into our own GL texture

The GPU path needs Qt to render into a texture the compositor can sample, which means Qt must use our EGL context. The only way to hand Qt a foreign context is QNativeInterface::QEGLContext::fromNative, and that call is implemented by the QPA platform plugin, not by Qt Gui. Measured here, Qt 6.11.2:

Platform pluginfromNative
offscreenreturns null
eglfsreturns null

With no plugin to adopt the context, Qt renders on a context of its own and the texture it produces is not one we can sample — the two contexts share nothing.

Re-tested 2026-09-06, after the DRM backend landed, with dev/qtprobe — which exists so the third person to ask does not have to write it again:

Platform pluginfromNative
offscreenreturns null
eglfsreturns null

Unchanged. This is a property of the installed Qt, not of anything here, so the probe is the thing to re-run after a Qt update.

Worse, and worth knowing: QQuickRenderControl::initialize() makes a GL context current on the calling thread on its way to failing. After that, the compositor’s own eglMakeCurrent fails with BAD_ACCESS — “another window API already has a current context” — and every subsequent frame fails to render. The compositor looked broken; it had simply lost its thread’s context to Qt.

The path taken

Qt’s software scene graph has no context requirement at all: QML rasterises into a QImage via QQuickRenderTarget::fromPaintDevice, and the compositor uploads it as a MemoryRenderBuffer. For chrome this is cheap — a 1600×34 bar is 218 KB — and it is uploaded only when Qt says the scene changed.

Two traps, both of which cost real time here:

QQuickRenderControl::initialize() returns false under the software adaptation and must not be called. It is an RHI call; the software renderer has no RHI. Treating the false as failure hid a working scene graph behind a wrong check, and calling it at all breaks the compositor’s context as above.

Do not clear the target image between frames. Qt’s software renderer repaints only the regions it considers dirty. Clearing every frame erases everything that has not changed, leaving only the parts that animate — which presented as a bar with one moving dot and no background or text, and looked exactly like a broken upload path. The image is cleared once, on creation and resize, and Qt owns it after that.

The software adaptation is selected by name, setSceneGraphBackend("software") or QT_QUICK_BACKEND=software — not by setGraphicsApi(Software), which selects between OpenGL, Vulkan, Metal and D3D and will accept Software without changing the adaptation. Getting it wrong fails later, in initialize(), with nothing pointing at the cause.

Two ways back to the GPU, when it is worth it

  1. A QPA plugin that adopts EGL contexts. QT_QPA_PLATFORM=wayland is a candidate for nested development but not for a real session, where there is no host compositor. Worth re-testing when the DRM backend lands.

  2. A dmabuf-backed render target. Qt renders on its own context into a dmabuf; we import it with ImportDma. No context sharing needed, and it is how two processes would do it anyway — but it needs both sides on the same GPU and correct format/modifier negotiation, which is exactly where this kind of work stalls on NVIDIA.

    What it involves, now that it is the only route left:

    • Allocate the target through GBM, which the DRM backend already has.
    • In Qt’s context, make an EGLImage from that dmabuf and bind it to a texture, then hand Qt QQuickRenderTarget::fromOpenGLTexture.
    • In our context, import the same dmabuf with import_dmabuf — which the compositor already does for every client buffer.
    • Fence between the two. EGL_ANDROID_native_fence_sync is present on this driver; without a fence this is a race that shows as intermittent garbage, which is the worst way to find out.
    • Keep two contexts on one thread and restore ours after every Qt render. QQuickRenderControl::initialize() makes its own context current on the calling thread, and the compositor’s eglMakeCurrent fails with BAD_ACCESS afterwards unless it is put back — see above.

    The risk is concentrated in the last two: NVIDIA’s driver is where dmabuf round-trips and cross-context fences are least forgiving, and a wrong fence fails intermittently rather than loudly.

Neither is on the critical path. Chrome is small, and the upload only happens when it changes.

Measured

docs/qml-bar.png — the bar rendered by Qt inside Solium, showing live compositor state: window count, the focused window’s xdg-shell title, and the compositor’s own clock. It is a record of 2026-09-04, not a picture of Solium today: that compiled-in bar was removed the next day, and the white titlebars in it are the terminal’s own, drawn before Solium had frames of its own.

Drawing a window as geometry, not a rectangle

Since then (2026-09-30). Steps 1, 2 and 4 of the order below shipped as written, on 2026-09-06. Step 3 shipped as script keys — rotate_x, rotate_y, rotate_z and perspective, and later z and pivot, documented in modes.md — but no Stage Manager mode: that is atrium, #85, not yet written. Step 5 became one built-in fragment effect, rounded corners, not shaders chosen by name. Frame has since gained z, pivot and zoom, and a window drawn through a matrix costs an offscreen pass as well as a quad. The genie is still the only deformation: a fold or a curl needs a depth per vertex, which the deformation interface does not carry. The two sections below that no longer held, Why hit-testing does not move and The cost, stated plainly, are corrected in place.

The wall we hit

A presentation transform can currently say exactly two things:

#![allow(unused)]
fn main() {
pub(crate) struct Frame {
    rect: Rectangle<f64, Logical>,
    opacity: f32,
}
}

A rectangle and an opacity. Every effect built on that is necessarily a move, a scale, or a fade, because the vocabulary has no other words. The genie attempt proved it: asked for a window that tapers into a dock icon, the honest best a rectangle can do is shrink toward it, and shrinking toward something is not what a genie looks like.

The three things wanted are each impossible in that model, and impossible in different ways:

  • Genie / suck — the window’s sides curve and its width varies down its length. Different rows of pixels land at different widths. No single rectangle describes it.
  • Stage Manager — the window is a card in perspective: parallel edges converge. That is a projective transform, and a projective transform is not affine, so no amount of scale-and-translate reaches it.
  • Fold / curl — the surface leaves the plane entirely.

What replaces it

A window is drawn as geometry with a texture on it. The rectangle becomes one case of that rather than the only case.

#![allow(unused)]
fn main() {
struct Frame {
    /// Where the window would be with no deformation. Still the anchor for
    /// hit-testing, for the frame, and for what a layout thinks it moved.
    rect: Rectangle<f64, Logical>,
    opacity: f32,
    /// A 4x4 applied about the rect's centre. Identity means flat.
    matrix: Mat4,
    /// A per-vertex deformation over a grid. None means four corners.
    deform: Option<Deform>,
}
}

Three layers, each strictly more expensive than the last, and a window pays only for what it uses:

LayerCostReaches
rect + opacityone quad, existing pathmove, scale, fade
rect + matrixone quad, projected cornersperspective, tilt, card flip, Stage Manager
rect + matrix + deforman N×M gridgenie, fold, curl, ripple

The identity case must stay on the existing path. Most windows most of the time are flat and opaque, and a compositor that renders every window through a mesh to support an effect nobody is currently running has made every frame worse to make one frame possible.

Why hit-testing does not move

rect remains the truth for input. A window drawn in perspective is still clicked where its flat rectangle is — the alternative is inverting a projective transform per pointer event and then explaining to a script why the window it placed is not where clicks land.

Corrected 2026-09-30. This section used to say that modes needing clicks to follow the drawn shape invert their own transform through to_window_space. None can. present::to_window_space is the compositor’s own inverse of the rectangle alone, its offset and its scale; no script can call it, and nothing inverts the matrix or the deformation. What shipped is this: a window takes clicks at the rect it is presented at, not the one its layout placed, which is why an overview thumbnail is clicked where it is drawn; a frame at an opacity of 1/255 or less takes no clicks at all; and the matrix, the deformation and z (added later, as draw order) never move a click.

What this needs from the renderer

Smithay offers all three pieces, at different depths:

  • Offscreen — render a window’s surface tree into a texture, so a deformed window is deformed as a whole rather than per subsurface. A window with a popup or a subsurface would otherwise tear apart along its own seams.
  • with_context — raw GLES, for drawing arbitrary geometry. Nothing above it can express a projected quad.
  • compile_custom_texture_shader — fragment effects within Smithay’s element system, for anything that is per-pixel rather than per-vertex.

The cost, stated plainly

render.rs carried a note that it was written against Smithay’s Renderer traits and nothing lower, because reaching into GLES is what would quietly close the door on a Vulkan backend. This crosses that line.

It is worth crossing, and the reason is that the alternative is worse: without it the compositor can only ever offer effects that are rectangles, which rules out the entire class of thing this project exists to do. The plan was to cross it in one place, warp.rs, so that a Vulkan backend would have one file to reimplement rather than a habit to unpick.

Since then (2026-09-30), the line was not held to one file. Raw GL calls are still only in warp.rs. But the render elements are typed on GlesRenderer, because the warp element can only be GLES; rounded corners are a GLSL ES program compiled through a GLES-only call (pass.rs) and drawn by a GLES render element of their own, and the fade a closed window leaves behind has one too (remains.rs); and QML on the GPU, the default since #147, shares its buffers and its fences with Qt through EGL. A Vulkan backend needs its own element set, not just its own warp. What a Vulkan port costs now lists every piece.

Order of work

  1. The vocabulary: matrix and deform on Frame, identity fast path kept.
  2. warp.rs: a textured quad with projected corners, in GLES.
  3. Perspective from a script — tilt and depth, which is Stage Manager.
  4. Subdivision: the grid, and genie as a vertex function over it.
  5. Fragment shaders by name, for the per-pixel effects.

The window provider

Date: 2026-09-06 Status: done, 2026-09-07. A spike record: the current design is in architecture.md, A window is a pane.

Since then (2026-09-30). Both leftovers listed under What is left are fixed: #34, and #35, by an activation token that moves a client into the window waiting for it when the process walk misses. A frame is now a field of the pane, not a table keyed by PaneId. The loading window’s frame is still built, but by default not drawn (loading.decorated = false), and its scene fades off the application that replaces it (loading.fade). The leaving content was built later, in #126: a window whose client has gone fades out from its last picture. X11 windows are never adopted; each opens a window of its own.

Why

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 being 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 smithay::desktop::Window, because that type requires a surface to exist.

The stand-in shipped on main is two objects handing over to each other. It reads correctly for one frame and is wrong underneath: two ids, two slots, a handover animation papering over the seam. No amount of polish fixes that, because the seam is the architecture.

What changes

The compositor stops working with desktop::Window directly. It works with a pane: our own window provider, which owns an identity and a slot, and whose content is one of

  • loading — a QML scene the compositor draws itself,
  • client — a mapped desktop::Window,
  • leaving — a client that has gone, drawn from what was last known of it while it animates out.

A pane keeps its id and its slot across all three. Adoption is the whole point: when a client whose process matches a loading pane maps a toplevel, the toplevel becomes that pane’s content. Nothing is created, nothing is replaced, and nothing else in the compositor notices.

QML is not a special case bolted on. A pane’s content being a scene is as ordinary as its content being a client, which is what makes a loading window, a placeholder for a crashed application, and a purely compositor-drawn surface the same mechanism rather than three.

What has to move

Every path that reaches for space.elements() today:

pathtodayafter
layoutscripts see mapped windowsscripts see panes, loading included
focuskeyboard focus needs a surfacea loading pane can hold focus and receive a close
renderwindow elements, then chromepane content, whatever it is
inputsurface_underpane under, then its content
decorationskeyed by surface idkeyed by pane id, so a frame survives adoption

The last row is the one that makes the seam disappear: a frame drawn around a loading pane is the same frame, with the same animation state, once the client arrives.

Order

  1. The type, alone. Pane, its content enum, ids, geometry. Tested, not yet wired to anything.
  2. The space becomes panes. One list of panes replaces Space<Window> as the compositor’s own view; Space stays underneath for the mapped case, because its damage and output bookkeeping is not worth rewriting.
  3. Layout and snapshot. Scripts see panes. A loading pane gets a slot, and the tiling arithmetic runs on the count that includes it.
  4. Adoption. new_toplevel matches the client’s process against loading panes before creating anything.
  5. Loading content and close. The QML scene, and closing a pane whose application never arrived.

Each step ends with the compositor working. Step 2 is the one that touches everything; steps 3 to 5 are where the behaviour people asked for appears.

Risks worth naming now

  • Adoption misses. A client that re-execs or forks past the ancestor walk maps a window with no matching pane. It must then open normally — a missed adoption is a window that appears the old way, never a window that is lost.
  • The application never arrives. A loading pane that waits forever holds a slot forever. It gives up, and the layout is told, exactly as if it closed.
  • Closing mid-load. The process is spawned and may connect after the pane is gone. The adoption path has to cope with a client whose pane no longer exists, again by opening normally.
  • Focus with no surface. Keyboard focus is a Wayland concept and a loading pane has nothing to give it to. The pane holds focus in our model and hands it to the surface at adoption; until then keys reach bindings and nothing else, which is what a loading window should do anyway.

Where this got to

Done. All five steps. A window’s life begins when the user asks for the application. It takes its place in the layout immediately, draws itself while it waits, can be closed before anything has connected, and the application appears inside it — same window, same id, same frame with the same animation still running in it.

  • 1 — pane.rs: Pane, PaneId, Content, Panes.
  • 2 — panes underneath everything: snapshot(), window_id, decorations (keyed by PaneId), drawing, every hit test. sync_panes is the single place the pane list and the space are reconciled.
  • 3 — a pane with no client is a real window to the layout. Presentation state moved onto Pane; begin_loading tells scripts the window opened, which is what puts it in a layout’s tree.
  • 4 — adoption, in new_toplevel, where the client is mapped. The old two-object stand-in (Launch and everything around it) is gone.
  • 5 — the pane draws its own QML, inside its own frame, and answers the pointer.

Space did not go away and is not going to. It is still the authority on where a mapped client is, what damage it did, and which surface is under a point within a window. Nothing above that level asks it what windows exist.

Configurable, in config.lua under loading: scene, patience, reserves_a_slot, decorated.

What is left, and where it is written down

  • #35 — an application that forks and exits is not adopted: the ancestry walk cannot follow a chain through init. It degrades correctly (an ordinary window, nothing lost) but the feature does not reach a whole class of programs. The fix is an activation token, which is #25’s mechanism, not a private one.
  • #34 — closing a window leaves keyboard focus nowhere. Pre-existing, found while verifying step 2.
  • window_under still answers only for a pane with a client, so clicking the body of a loading window does not raise or focus it. surface_under stops there, so the click reaches nothing behind it; it simply does nothing. A loading window cannot hold keyboard focus anyway — that is the last of the four risks this document opened with, and the answer it proposed.
  • SOLIUM_LOADING_AT is scaffolding from step 3 and outlived its purpose now that spawn does this for real. It is still the only way to reach a window whose application will never arrive, which is worth keeping for testing patience and closing mid-wait.

The risks this document opened with, as they turned out

  • Adoption misses — happens, exactly as predicted, and opens an ordinary window. Now #35.
  • The application never arrives — settle_loading, and patience is a setting. Verified by cutting it to 60ms.
  • Closing mid-load — works; the pane goes, its frame goes with it, the layout heals. Finding this needed a frame on the loading window, which is why step 5 gave it one.
  • Focus with no surface — a loading pane never takes keyboard focus, and keys reach bindings only. As proposed, unchanged.

Pane styles: layered decorations in QML

Status: design, approved to plan. Not implemented.

A pane’s appearance becomes a style bundle — a folder of QML declaring one or more layers, each of which may sit behind the client, around it, or over it, and each of which may paint past the pane’s own edge. Styles are selected per pane by rule, so one application can look nothing like another.

The target is a capability no compositor currently has: a window whose border reaches beyond its own rectangle and reacts, while the application inside it neither moves nor knows.

Why

Today a decoration is one QML file rendered into one buffer the size of the window’s outer rect, drawn at one depth. That fixes three things a style author cannot get around:

  • It cannot draw behind the client. One element, one depth, and the client is composited over it.
  • It cannot draw outside the window. The buffer is the outer rect, so paint beyond it has nowhere to go.
  • It is one file for the whole look. A style with a glow, a titlebar and an overlay is one file doing three jobs, or it is impossible.

The last of these also blocks the thing that makes ricing worth doing: a look should be a folder you can zip and hand to somebody.

What a style is

A folder under panes/, looked up the way decorations already are — the user’s directory first, then the one that ships, by name:

~/.config/solium/panes/neon/
    Pane.qml          the manifest, and for a simple style the whole thing
    Frame.qml         a layer, when one grows big enough to deserve a file
    Spikes.qml
    Theme.qml         this style's own colours, shadowing Solium.Theme
    assets/

Pane.qml is the entry point, and it is QML rather than Lua deliberately: style lives in QML, and Lua configures the compositor. The manifest is a description of how a thing looks, so it belongs on the QML side of that line.

import QtQuick
import Solium

PaneStyle {
    // Reserved from the client, once, for the whole style. Not per layer:
    // the client is placed once and every layer sees the same client rect,
    // so three layers each declaring insets would be three answers to one
    // question.
    insets.top: 32

    Layer {
        depth: "behind"
        bleed: 200
        Glow { anchors.fill: parent }
    }

    Layer {
        depth: "frame"
        source: "Frame.qml"
    }

    Layer {
        depth: "above"
        bleed: 160
        source: "Spikes.qml"
    }
}

A layer’s content is written inline or delegated with source:, and the syntax does not change between the two. A plain titlebar is one file with one inline layer; a style with three animated layers is a folder. There is no threshold to cross and no format to migrate between.

Layer

property
depth"behind" | "frame" | "above"where the client’s surface sits relative to this layer. Default "frame"
bleedint, or {top,right,bottom,left}logical pixels this layer may paint past the pane’s outer rect. Default 0
sourcestringa QML file in the bundle, when the content is not inline
namestringfor diagnostics only — which layer a warning is about

Per-side bleed exists because a bar that throws spikes upward should not pay for three sides it never touches: the canvas is the cost, and it is the product of the sides asked for.

PaneStyle

Holds the layers, the insets, and later the client treatment (see Reserved).

It is also where a rule’s options land: they are set as properties on the PaneStyle root, and layers read them from there —

PaneStyle {
    property real hue: 200            // default, overridden by a rule
    Layer { depth: "above"; Spikes { color: Qt.hsla(parent.hue / 360, 1, 0.6, 1) } }
}

— rather than being set on every layer. One object owns a property, layers reference it, and a style with three layers does not have three copies of hue that can disagree.

What the compositor sets on every layer

Unchanged from the current contract, and now per layer rather than per decoration: title, focused, pointerInside, contentWidth, contentHeight. Read back: action, hovered.

A layer additionally gets paneWidth, paneHeight — the outer rect — and bleedLeft, bleedTop, so QML can position against the window’s own corner rather than the canvas corner, which is offset by the bleed.

Rendering

Each layer is rasterised into its own texture and becomes its own element in the frame’s element list. The client’s surface is placed between the behind elements and the frame/above ones, which is the whole reason layers are separate scenes rather than one image: nothing else lets a client be sandwiched inside something a single QML file produced.

Element order for one pane, topmost first, is above, frame, client, behind. The pane’s own position in the list is unchanged, so:

Bleed follows the pane’s stacking. A layer that paints past its pane overlaps whatever is below that pane and is covered by whatever is above it. A background window’s effects do not paint over the window being typed in, and a focused window’s effects reach over everything behind it without asking.

Bleed is a promise, not a request. A layer is clipped to the canvas it declared. Without that, damage is unbounded: one style could force a full-screen repaint every frame and the cost would appear as the whole desktop stuttering, with nothing pointing at the style that caused it.

Damage includes the bleed. A layer’s damage rect is its canvas, not the pane, or an animating bleed leaves trails over its neighbours.

Input stays clipped to the pane’s outer rect, whatever the bleed. A spike reaching over the next window must not eat that window’s clicks. The failure mode of getting this wrong is a neighbour that has silently stopped responding, with nothing on screen to explain it — which is much worse than an effect that is merely decorative.

Cost

Three layers with bleed is several times the rasterisation of today’s single banded frame, per window, per frame. The current band optimisation — copying only the inset strips when a frame stays inside them — still applies to a frame layer with no bleed, and cannot apply to a bleeding one.

This is why the GPU render target is a prerequisite rather than an improvement.

Selection

Lua selects; QML describes. The global default and a rule per application:

pane = "neon",

rules = {
    { app_id = "spotify", pane = "wave", options = { hue = 280, speed = 1.4 } },
    { app_id = "mpv",     pane = "none" },
},

options are set as properties on the PaneStyle root — see above — so one wave bundle serves many looks without a folder each.

This is a slice of #56 (window rules). Only the style-selecting predicate is in scope here; placement, floating and workspace assignment stay with that issue.

Migration from decoration

decoration is replaced by pane. The eight shipped decorations each become a one-layer bundle.

A rice should be one folder and one word. Keeping both concepts — “a decoration is a file, a pane style is a folder” — means every doc page and every dotfiles repo has to explain which is in use and why, permanently, which is how configuration formats become the thing people complain about.

decoration = "top" is accepted as a silent alias for pane = "top", and SOLIUM_DECORATION for SOLIUM_PANE. That costs one line and breaks nobody. Converting the shipped eight is a second gain: they stop being special cases and become eight readable examples of the format an author is about to write.

Pane ownership

Decorations holds two parallel tables keyed by PaneId — frames and bare — reconciled by hand in sync_panes against a set of live panes. Two tables answering one question (“does this pane have a frame?”) can disagree: today insets_of checks frames first, so a pane in both has bare silently ignored. Nothing tests that.

With three layers per pane it becomes three entries to keep in step instead of one. The pane should own its frame state as a single value:

#![allow(unused)]
fn main() {
enum Frame {
    Pending,          // a client is coming; keep reserving its insets
    None,             // client-side decorations, or override-redirect. Never a frame
    Styled(PaneStyle),
}
}

insets_of becomes a pure function of the pane, the decoration retain disappears, and the illegal state stops being representable. closing and asked are per-pane timers retained for the same reason and move in with it.

hovered_frame stays where it is: it is a property of the pointer — which pane is hovered — not of a pane, and moving it in would mean a bool on every pane and a scan to find the one that is true.

This does not fix #33. That leak was isolated to the GL/EGL buffer import path. Unifying these lifetimes removes a class of leak that has bitten here before — a client that crashed used to leave its frame behind forever — but crediting it with #33 would be wrong.

Prerequisite: the GPU render target

QML currently rasterises on the CPU. Qt’s software scene graph was chosen because context adoption is impossible on this platform plugin — QNativeInterface::QEGLContext::fromNative is unimplemented — and the consequence is recorded in docs/spikes/2026-09-04-qml-in-compositor.md: a full-width gradient animating at 260 Hz costs about a tenth of a core, inside one window.

Three layers, each on a canvas larger than the window, animating, is several times that per window. It does not fit on the CPU.

The route is the spike’s option 2, the dmabuf-backed render target: allocate through GBM, give Qt QQuickRenderTarget::fromOpenGLTexture over an EGLImage of that buffer, import the same dmabuf on our side, fence with EGL_ANDROID_native_fence_sync, and restore our EGL context after every Qt render.

Stated risk, accepted. The spike names this as where the work stalls: this is an NVIDIA machine, dmabuf round-trips and cross-context fences are where that driver is least forgiving, and a wrong fence fails intermittently rather than loudly. The decision was to design the whole thing now and carry the risk rather than spike it first. If the fence proves unreliable, everything below the prerequisite still stands — it would need the ambition bounded to what the CPU can carry, which is roughly one animated layer with modest bleed.

Reserved, not built

PaneStyle declares client treatment from day one so that styles are written against the final format, and the compositor ignores it until it is built:

PaneStyle {
    client.radius: 12
    client.shadow.blur: 40
}

Rounding a window’s corners and drawing its shadow are not decoration layers: they change how the client’s own surface is composited. That drags in damage tracking, opaque-region culling — a rounded window is no longer fully opaque, so what is behind it can no longer be skipped — and subsurface clipping. Each can regress performance in ways that only appear on real hardware.

It gets its own design after this one. Reserving the key now means a style folder written today does not change shape when it arrives.

Out of scope

No IPC or control socket. No PipeWire. No per-window audio levels, and therefore no effect that genuinely follows what an application is playing — a rule-driven wave border animates because the rule matched, not because music is playing, and not on the beat. Real reactivity needs a channel into a running compositor, which is its own subsystem and is deliberately not started here.

pid is not exposed to scripts. It would be the way to match an audio stream to a window, and nothing in this design needs it.

Testing

Layers and z-order are visual and capturable. Two windows overlapping, the lower one carrying a behind layer with bleed: assert the upper window’s pixels win where they overlap, and that the bleed is visible outside the lower window where nothing covers it. The compositor reads back its own framebuffer, so this is a frame comparison rather than a screenshot tool.

Existing decorations must be byte-identical. Every shipped style converted to a one-layer bundle, rendered, and compared frame-for-frame against the current build. A conversion that changes a pixel is a bug in the conversion.

Input clipping needs its own check. A window with a large bleed over a neighbour, a scripted drag into the bleed region, and the neighbour must receive the press. SOLIUM_DRAG_AT goes through the real pointer path; SOLIUM_CLICK_AT does not and would pass while proving nothing.

Bleed clipping. A layer that paints deliberately outside its declared canvas: assert nothing appears beyond it.

The GPU render target cannot be fully verified nested. The dmabuf path needs the DRM backend and real hardware. Nested runs on the host’s GPU through winit and will not exercise the same allocation or the same fence.

Order of work

  1. GPU render target. The prerequisite and the risk. Nothing below it is affordable without it.
  2. PaneStyle and Layer types, layered rendering. The capability, with the shipped decorations still in their current form.
  3. panes/ bundles, and converting the shipped eight. The format, proven by migrating every existing style to it.
  4. Rules. Per-pane selection.
  5. Pane ownership. Last on purpose: it touches the most call sites and gains most from knowing the final shape.

Each stage is landable alone. Stage 2 without 3 gives layered decorations in today’s single-file form; stage 3 without 4 gives bundles with one global style.

Stage 1 wants its own implementation plan. It shares nothing with the rest but a dependency: it is GBM allocation, EGL image binding, cross-context fencing and context restoration, none of which touches panes, QML contracts or Lua. Planning it together with stages 2–5 would produce one plan where half the steps cannot be checked by anyone reading the other half. Stages 2–5 are one plan; stage 1 is another, and it goes first.

The scene: panes, surfaces, groups and effects

Status: design. Absorbs 2026-09-08-pane-styles-design.md, whose decisions all still hold, and answers #84, #85, #87 and #89 as one thing.

docs/modes.md states the standard this design is measured against:

If a new mode needs new Rust, the transform layer is missing something, and that missing thing is the bug rather than your mode.

Three modes have now been written against that standard by people who were not the author. Each worked, and each came out different from its design. What they could not say is this document’s requirements list.

The evidence

A cover-flow deck (rice/prism/deck.lua, written from outside the project in Lua only). Worked. But there is no z, so cards had to be spaced until they never overlap; and warp.rs:142 always pivots on the centre, so they could not rotate about a near edge. The available workaround for depth is worse than the gap: sol.focus restacks, so a script can only express depth by moving the keyboard — and then a switcher sends focus events to clients as the user scrubs.

Atrium (#85), not yet written, and deliberately chosen as the hardest test. It needs a window at arbitrary scale (there), several windows moving as one (not there), stacking from a script (not there), a swap where two windows exchange places along a path rather than cross-fading (not there), and a grab scoped to the strip while the centre stays live (not there).

Workspaces, which ship. workspaces.lua moves every window individually in a loop, and the wallpaper, the bars and the layer surfaces do not come with them — because scripted::Surface has name, layer, pointer and area_on, and no transform at all. A surface is drawn but not addressable.

What is missing, named separately

Four gaps. Each has a different fix, and conflating them is how this becomes a rewrite rather than a series of landable stages.

absentcosts
Completenessz, pivot, per-node alphaoverlap, hinges, page turns, a receding strip
Addressonly panes can be transformedwallpaper left behind, no groups, no desktop
Motiona transform is a destinationno arcs, no swaps, no paths
Compositionone flat element list, one submitblur, shadows, rounded corners

The genie needs none of the four. That is exactly why it was buildable as a hardcoded enum arm with one variant — and why the next five effects are not.

The model

One addressable scene. A transform names a selection. Selections compose.

That is the whole idea, and everything below is a consequence of it.

What is addressable

output          every node drawn on it
pane            a window and its chrome
surface         a sol.surface instance: wallpaper, bar, dock, scrim
layer           one depth within a pane's style
group           a named set of any of the above

A group is not a new kind of thing. It is a name for a selection, and a transform on it composes with each member’s own. That composition is the point: a window individually tilted inside a rotating desktop stays tilted within it, which is what makes a cube of workspaces different from rotating every window by hand and hoping.

sol.group("desk-2", { workspace = 2, surfaces = { "wallpaper" } })
sol.present_group("desk-2", { x = -2560 }, { duration = 300 })

One call, one animation, one target. The wallpaper comes because it is in the selection, not because the compositor knows what a wallpaper is — and it does not; sol.surface is one primitive doing five jobs and that stays true.

This single mechanism answers #89’s output-level transform (a selection that is an output), #85’s groups (a named set), and the workspace slide (windows plus surfaces), without three features.

What every node carries

#![allow(unused)]
fn main() {
struct Node {
    rect: Rectangle<f64, Logical>,   // where it lives; still the truth for input
    opacity: f32,
    matrix: Mat4,
    deform: Option<Deform>,          // vertex, CPU-side, parametric
    effects: Vec<Effect>,            // fragment, GPU, declares its inputs
    z: f32,                          // depth. Equal values keep list order
    pivot: (f32, f32),               // what `matrix` turns about, 0..1 of rect
}
}

z is depth and nothing else. It is not #84, which is about which of three places owns a window’s position.

pivot cannot be done in script.rs alone. Composing translate(-p) · R · translate(p) there needs the window’s size, and transform_from only sees the options table — so it would work when a script passed an explicit rect and silently do the wrong thing otherwise. It belongs at the two lines in warp.rs that compute the centre today.

Hit-testing does not move

rect remains the truth for input. A window drawn in perspective is still clicked where the layout put it. The alternative is inverting a projective transform per pointer event and then explaining to a script why the window it placed is not where clicks land. Modes that need clicks to follow the drawn shape already invert their own transform through to_window_space; that stays their business.

Motion: paths, not just destinations

sol.present takes a destination rectangle, so a window can only ever move in a straight line to it. Atrium’s swap — the outgoing window travelling out to the strip while the incoming one grows from where it sat — is the difference between “it moved” and “it was put there”.

sol.present(id, { rect = centre, via = { arc = 0.4 } }, { duration = 320 })

via is a named path function with parameters, resolved the same way an easing is — crates/animation already proves the pattern, and a path is a curve through space exactly as an easing is a curve through time. Absent means a straight line, which is what every mode does today and what it keeps costing.

The same reasoning that keeps vertex deformation parametric applies: a path moves the bounding box, so the damage tracker has to be able to ask where it will be.

Effects: geometry and pixels are different kinds

Vertex deformation is CPU-side and parametric — a named function with parameters, never user code. The damage tracker needs the deformed bounding box before anything is drawn, and a shader cannot tell it one. An effect must also be testable without a session: crates/effects is crates/animation again — empty [dependencies], unit tests, a wasm preview with live sliders.

Fragment effects are arbitrary GLSL, because they do not move the bounding box. A bad one is a wrong picture, which is recoverable. A bad vertex function would be a wrong damage rect, which is a corrupt screen.

This is also why a hung fragment shader is acceptable risk and a hung vertex function would not be: a GPU reset is a driver problem no Rust-side rule can catch, and keeping the geometry half in a language where a mistake is a wrong number is what makes the other half affordable.

An effect declares its inputs, and that is what creates a pass

effect { name, parameters, inputs }

Most declare nothing and draw inline, exactly as today. One that declares backdrop needs what is already composited beneath it: the renderer closes the current pass, binds its output as a texture, and opens a new one.

effectinputswhy
wavy border, glow, spikes—draws over what is there
rounded cornersselfmasks the node’s own texture
shadowselfderived from the node’s silhouette
blurbackdropsamples what is behind

Without this, blur is not merely unimplemented — it is unrepresentable, because a flat element list has nowhere to say “after the things below me, before the things above me”.

offscreen::capture already renders a node’s surface tree into a texture and is how self is obtained. It currently allocates per frame per warped pane, forty lines below a comment explaining why that is hundreds of megabytes a second; that is a prerequisite, not a follow-up, because an effect system multiplies it by the number of animating nodes.

Layers and bleed

A pane’s appearance is a style bundle — a folder under panes/, looked up user-directory-first. Pane.qml is the manifest, and it is QML rather than Lua deliberately: style lives in QML, Lua configures the compositor.

PaneStyle {
    insets.top: 32
    requires: ["gpu"]

    Layer { depth: "behind"; bleed: 200; Glow { anchors.fill: parent } }
    Layer { depth: "frame";  source: "Frame.qml" }
    Layer { depth: "above";  bleed: 160; source: "Spikes.qml" }

    client.radius: 12          // an effect with inputs: self
    client.shadow.blur: 40     // an effect with inputs: self
}

Ruling — three depths, not arbitrary nesting. behind, frame, above. Everything named in months of design fits; nesting is unbounded cost for a case nobody has asked for, and can be added later without breaking the format because a depth is a string.

Three rules on bleed, carried unchanged because they were right:

  • Bleed follows the pane’s stacking. A background window’s effects do not paint over the window being typed in; a focused window’s reach over everything behind it without asking.
  • Bleed is a promise, not a request. A layer is clipped to the canvas it declared, or one style can force a full-screen repaint every frame and the cost appears as the whole desktop stuttering with nothing naming the cause.
  • Input stays clipped to the pane’s outer rect. A spike over the next window must not eat that window’s clicks. The failure mode is a neighbour that has silently stopped responding.

client.radius and client.shadow stop being reserved keys and become effects with inputs: self. The reason they were deferred is unchanged and now handled rather than avoided: a rounded window is no longer fully opaque, so opaque-region culling cannot skip what is behind it. Under this design that is a property the node declares and the renderer’s culling reads, rather than a global assumption that quietly stops being true.

Input, scoped

script_grab is all-or-nothing. Atrium wants the strip to take clicks while the centre window stays live, and an interactive sol.surface currently swallows every press inside its rectangle whether or not the QML under the pointer wanted it — which is why Prism’s dock has to be sized to its contents and re-placed on every window open and close.

Both are the same missing thing: a grab, and a surface, should be able to say which input it wants. The scene already knows whether a MouseArea accepted; the answer is simply not propagated.

The QML contract

The software scene graph does not implement ShaderEffect, and Canvas appears not to paint on it; the GPU path does both. So which QML is legal depends on which machine you are on, and a style written on one hands a white rectangle to another, silently.

Ruling — a style declares what it requires and is refused at load when the path cannot provide it. requires: ["gpu"], absent meaning portable. The alternatives are GPU-only styles (software is still the default, so most people get nothing), a lowest-common-denominator subset (caps every author to protect a case they may not care about), or silence (what happens today). requires is a list, so ["gpu", "effects/2"] is the same mechanism as versioning.

What stays cheap

A node with no transform, no effect and no bleed emits exactly the element it emits now, through exactly the path it takes now. The spike’s rule is load-bearing:

a compositor that renders every window through a mesh to support an effect nobody is currently running has made every frame worse to make one frame possible.

Every addition above is opt-in per node, and a desktop that opts into nothing pays nothing.

Genie, and the one after it

Genie becomes an entry in crates/effects: a named vertex function over a grid, with parameters, unit tests and a preview slider. present.rs stops knowing what a genie is. It gains two things in the move:

  • A morph between two anchors, not “minimise into a slot”. The anchor is an identity resolved per frame by the compositor — a pane id, a chrome element — not a rect passed from Lua. A rect snapshotted at dispatch aims at where the dock icon was 500 ms ago.
  • The phase axis becomes a parameter. It is hardcoded to (1.0 - v) today, which is why a side dock is inexpressible. The grid must follow the axis: genie subdivides 48 along v and 8 along u because v is the phase axis, so a parametric axis with a fixed grid gives a side dock eight steps and a flipbook.

Adding fold, curl, ripple or page-turn is then a file in that crate. That is the whole test of this document.

Out of scope

No IPC and no PipeWire, so no effect genuinely follows what an application is playing. A rule-driven wave border animates because the rule matched, not because music is playing and not on the beat. Real reactivity needs a channel into a running compositor; it is its own subsystem and is deliberately not started here.

Arbitrary layer nesting, per-layer insets, and effects on layer-shell surfaces are deferred, and none is blocked by anything above.

Order of work

Three phases, in this order and for this reason: the machine, then the controls, then the things made with it.

Configuration designed before its mechanism exists describes what somebody imagined rather than what was built. config.lua already carries a whole dock section — items, morph — for a thing that appears nowhere in crates/ outside comments, and architecture.md promises a corner radius and a z-order that are not in the source. Both were written alongside the intention. This order is how the next one is avoided.

Each stage leaves the compositor running and testable, and each is landable alone.

Phase 1 — the machine

  1. Pane ownership. The “Pane ownership” section of 2026-09-08-pane-styles-design.md. One value answers “does this pane have a frame”, and the illegal state stops being representable. Everything below adds state to a pane; this is what stops that becoming five more tables.

  2. offscreen::capture caching. It allocates a texture per frame per warped pane today, forty lines below a comment explaining that per-frame allocation is hundreds of megabytes a second. A prerequisite rather than a follow-up: an effect system multiplies it by the number of animating nodes.

  3. crates/effects, with genie moved into it. No new capability — the capability is that the second effect is a file rather than a patch to present.rs.

  4. Address: surfaces and groups become transformable. A transform names a selection. This is one change with two visible results: the workspace slide stops leaving the wallpaper behind, and “the whole screen” and “this workspace” become nameable — which is what a global or per-workspace shader needs before any shader exists.

  5. Layers with depth and bleed. Moved up from phase 3, where it sat because it was mistaken for styling. It is a capability, it is the one originally asked for, and it needs none of what follows: a layer is a QML scene rendered to its own texture and placed in the element list at a depth relative to the client, which is the element machinery that already exists. Passes are only needed by effects that read an input.

  6. Passes, and one effect through them. An effect declares its inputs; one declaring backdrop splits the frame. Land it with rounded corners and nothing else — the cheapest effect that declares self, and the one that forces the opaque-region question the whole design rests on. One effect proven end to end is what makes the next phase design against a real mechanism.

  7. z, pivot, node alpha. Completes Frame. Not a prerequisite for anything above, which is why it sits at the end of the phase rather than the start — but it is what the deck, atrium and any card stack need.

Phase 2 — the controls

  1. The configuration surface, for every scope at once. Pane and client are sketched above (PaneStyle, client.radius, rules by app_id). The others are not designed yet and must be before they are built: a global screen effect, a per-workspace effect, and a transitional effect on open and close — which has to be distinct in the API from a persistent one, because a permanently-shaded window pays an offscreen pass every frame for its whole life and a dissolve pays it for 300 ms.
  2. Rules. Per-pane selection by app_id, a slice of #56.

Phase 3 — the things made with it

  1. The effects worth shipping: shadows, blur, wavy and reactive borders. By this point each is a file in crates/effects or a shader named in a style bundle, which is the whole test of this document.
  2. Input scoping. The surface press question and the scoped grab.
  3. panes/ bundles, and converting the shipped eight.

Atrium is the acceptance test throughout. It is not a stage because it should need nothing that is not already here — and if it does, that thing is the next entry.